mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 22:19:57 +00:00
Compare commits
194 Commits
v0.9.9-pat
...
v0.9.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f19f18dc9 | ||
|
|
a465597e78 | ||
|
|
dbfcb441f7 | ||
|
|
3fb2ba318d | ||
|
|
8f039b3a53 | ||
|
|
c939686509 | ||
|
|
07aff1fe02 | ||
|
|
5f27edcd19 | ||
|
|
f47d473e63 | ||
|
|
7a2bd38700 | ||
|
|
f8c40ecca6 | ||
|
|
2bc991685f | ||
|
|
87811a0493 | ||
|
|
0885597427 | ||
|
|
0952973887 | ||
|
|
6b30f042fa | ||
|
|
efb8f1f5b8 | ||
|
|
de3cf9893d | ||
|
|
fe02e9a066 | ||
|
|
84745d5ca4 | ||
|
|
cdb1c06ad2 | ||
|
|
ef0647285c | ||
|
|
33b1fad5f8 | ||
|
|
b899122dfe | ||
|
|
50c04a62f9 | ||
|
|
554b68484c | ||
|
|
6a1c046714 | ||
|
|
0b37bdddc6 | ||
|
|
563a426c00 | ||
|
|
f6a5d9ef7e | ||
|
|
a7d2450704 | ||
|
|
75fced3d9c | ||
|
|
5a1bbd1059 | ||
|
|
c133678cb1 | ||
|
|
1fc3c4b09d | ||
|
|
77c4c3e804 | ||
|
|
bc1f747418 | ||
|
|
62edac7c7f | ||
|
|
ff839df279 | ||
|
|
8b8511b19e | ||
|
|
7598753f4e | ||
|
|
68777bf05f | ||
|
|
b6217b22b0 | ||
|
|
196fa135fd | ||
|
|
ff3225ab44 | ||
|
|
ab36de3725 | ||
|
|
2b4617dc1b | ||
|
|
e169818404 | ||
|
|
c1a696e6f0 | ||
|
|
e07347ac53 | ||
|
|
fd38abd562 | ||
|
|
293c0277a8 | ||
|
|
344a799fcf | ||
|
|
35192e5675 | ||
|
|
9e80e4e7e5 | ||
|
|
e7bef097dd | ||
|
|
41b2341b0b | ||
|
|
e1a52f1d5a | ||
|
|
d8dc8029c0 | ||
|
|
87bc4ba419 | ||
|
|
850a553958 | ||
|
|
d9b5748f80 | ||
|
|
974df5e7b9 | ||
|
|
06cd774c10 | ||
|
|
4419be9c09 | ||
|
|
de93fa5f5f | ||
|
|
1c5de38219 | ||
|
|
cb0475671e | ||
|
|
f90f5cebcb | ||
|
|
e2d88096a0 | ||
|
|
fb3b27a626 | ||
|
|
af0f542db8 | ||
|
|
bdd5eca59a | ||
|
|
1a8d89c410 | ||
|
|
a62d96c1f1 | ||
|
|
d56e162c99 | ||
|
|
46b9a88f16 | ||
|
|
d0c45a01fa | ||
|
|
e082268533 | ||
|
|
43ee7a98b4 | ||
|
|
8ffa961db1 | ||
|
|
e87b460070 | ||
|
|
65355d8863 | ||
|
|
3dc4d6c39e | ||
|
|
019412c27a | ||
|
|
96a2b81aaa | ||
|
|
fb610e62a0 | ||
|
|
736f7b55b7 | ||
|
|
2fd33ea294 | ||
|
|
53123aaf94 | ||
|
|
f8f5d26600 | ||
|
|
c86bc94d9d | ||
|
|
50e8639a40 | ||
|
|
424325162e | ||
|
|
a9a8676f7c | ||
|
|
14295f0035 | ||
|
|
29e70acc55 | ||
|
|
8599b348c0 | ||
|
|
6a761c2dba | ||
|
|
df2ee649ab | ||
|
|
f6b32a664a | ||
|
|
00782aae88 | ||
|
|
70f8a59a65 | ||
|
|
a4cf9bb6fe | ||
|
|
ab30f584cc | ||
|
|
9629c8a771 | ||
|
|
fc56f45628 | ||
|
|
8f862152e8 | ||
|
|
ab6dc79600 | ||
|
|
52d9b8cc78 | ||
|
|
6a96ddea76 | ||
|
|
f1ac5606ee | ||
|
|
b2d9771a57 | ||
|
|
c4ca9d7c3b | ||
|
|
303feafc3c | ||
|
|
b2de5e229c | ||
|
|
8297723d91 | ||
|
|
90f1dafb55 | ||
|
|
cba21eb8c7 | ||
|
|
1f419a3c71 | ||
|
|
74e5e640c5 | ||
|
|
c4ea095ae0 | ||
|
|
1ded19795a | ||
|
|
158b46eb4b | ||
|
|
2581bea93e | ||
|
|
c3ed6a689e | ||
|
|
f60896a838 | ||
|
|
36c603f3b2 | ||
|
|
1c582cde31 | ||
|
|
94a6f3eb57 | ||
|
|
3058fae145 | ||
|
|
687e455051 | ||
|
|
39a4c9ac02 | ||
|
|
47bfea1eae | ||
|
|
d7b6d0cd34 | ||
|
|
2b70095b47 | ||
|
|
45ebcd4f11 | ||
|
|
3dbe0c2067 | ||
|
|
0654718b34 | ||
|
|
6791eb72ba | ||
|
|
cb3537f529 | ||
|
|
471fd3a3b2 | ||
|
|
810641a264 | ||
|
|
b7896585fd | ||
|
|
179697ba61 | ||
|
|
032f159509 | ||
|
|
95a2d02df9 | ||
|
|
3ac9ff6028 | ||
|
|
fcf0f952b1 | ||
|
|
b99099fcbe | ||
|
|
bf66bbe5fa | ||
|
|
e80b442dd6 | ||
|
|
431b3a84f6 | ||
|
|
098e6e7f2b | ||
|
|
afcbff6644 | ||
|
|
ce1fde8500 | ||
|
|
4661399639 | ||
|
|
ac3baacec7 | ||
|
|
78d8d458ca | ||
|
|
e20a287c4b | ||
|
|
c7ab0f4f3d | ||
|
|
0d1057830b | ||
|
|
dd1cac3f2e | ||
|
|
5c792263ba | ||
|
|
37776c5083 | ||
|
|
fa81fe9396 | ||
|
|
f0a727ccb8 | ||
|
|
b77bf11b02 | ||
|
|
cdbc7a9510 | ||
|
|
c693bfee5e | ||
|
|
7156bf2382 | ||
|
|
c216527f23 | ||
|
|
b1de0f49df | ||
|
|
525ca09f2c | ||
|
|
92fc973bc3 | ||
|
|
22ff8e2cbe | ||
|
|
1ec664a348 | ||
|
|
6a24c37c0e | ||
|
|
8965fc49c9 | ||
|
|
735386c0b9 | ||
|
|
58c4da0ddf | ||
|
|
fe68488b1c | ||
|
|
25af6e6f77 | ||
|
|
e2d3b46a3a | ||
|
|
fade73d970 | ||
|
|
6ccb404c94 | ||
|
|
95cb7fc862 | ||
|
|
01925858ec | ||
|
|
0c01fd406d | ||
|
|
a97dbdf95c | ||
|
|
7e46c43f6f | ||
|
|
a7d6a8b0d0 | ||
|
|
dc6fbffa96 | ||
|
|
99b9a34e19 |
@@ -63,10 +63,13 @@
|
||||
# 是否统计图片token
|
||||
# GET_MEDIA_TOKEN=true
|
||||
# 是否在非流(stream=false)情况下统计图片token
|
||||
# GET_MEDIA_TOKEN_NOT_STREAM=true
|
||||
# GET_MEDIA_TOKEN_NOT_STREAM=false
|
||||
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
|
||||
# DIFY_DEBUG=true
|
||||
|
||||
# LinuxDo相关配置
|
||||
LINUX_DO_TOKEN_ENDPOINT=https://connect.linux.do/oauth2/token
|
||||
LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
|
||||
|
||||
# 节点类型
|
||||
# 如果是主节点则为master
|
||||
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -22,6 +22,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Determine Version
|
||||
run: |
|
||||
VERSION=$(git describe --tags)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
@@ -31,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
cd web
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
@@ -40,13 +44,11 @@ jobs:
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
go mod download
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
- name: Build Backend (arm64)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
VERSION=$(git describe --tags)
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -65,6 +67,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Determine Version
|
||||
run: |
|
||||
VERSION=$(git describe --tags)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
@@ -75,7 +81,7 @@ jobs:
|
||||
run: |
|
||||
cd web
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
@@ -84,7 +90,6 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -105,6 +110,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Determine Version
|
||||
run: |
|
||||
VERSION=$(git describe --tags)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
@@ -114,7 +123,7 @@ jobs:
|
||||
run: |
|
||||
cd web
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
@@ -123,7 +132,6 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -132,5 +140,3 @@ jobs:
|
||||
files: new-api-*.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.idea
|
||||
.vscode
|
||||
.zed
|
||||
upload
|
||||
*.exe
|
||||
*.db
|
||||
@@ -10,10 +11,13 @@ web/dist
|
||||
.env
|
||||
one-api
|
||||
new-api
|
||||
/__debug_bin*
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.gocache
|
||||
.cache
|
||||
web/bun.lock
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
electron/dist
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$
|
||||
FROM alpine
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --no-cache ca-certificates tzdata ffmpeg \
|
||||
&& apk add --no-cache ca-certificates tzdata \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder2 /build/new-api /
|
||||
|
||||
540
README.en.md
540
README.en.md
@@ -1,19 +1,17 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
# New API
|
||||
|
||||
🍥 Next-Generation Large Model Gateway and AI Asset Management System
|
||||
🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
|
||||
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<p align="center">
|
||||
<a href="./README.md">中文</a> |
|
||||
<strong>English</strong> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||
@@ -32,6 +30,21 @@
|
||||
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-quick-start">Quick Start</a> •
|
||||
<a href="#-key-features">Key Features</a> •
|
||||
<a href="#-deployment">Deployment</a> •
|
||||
<a href="#-documentation">Documentation</a> •
|
||||
<a href="#-help-support">Help</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 📝 Project Description
|
||||
@@ -40,186 +53,395 @@
|
||||
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support.
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes.
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||
|
||||
<h2>🤝 Trusted Partners</h2>
|
||||
<p id="premium-sponsors"> </p>
|
||||
<p align="center"><strong>No particular order</strong></p>
|
||||
---
|
||||
|
||||
## 🤝 Trusted Partners
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||
/></a>
|
||||
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||
src="./docs/images/pku.png" alt="Peking University" height="120"
|
||||
/></a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
||||
/></a>
|
||||
<a href="https://www.aliyun.com/" target=_blank><img
|
||||
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||
/></a>
|
||||
<a href="https://io.net/" target=_blank><img
|
||||
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||
/></a>
|
||||
<em>No particular order</em>
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
## 📚 Documentation
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target="_blank">
|
||||
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
|
||||
</a>
|
||||
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
|
||||
</a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||
</a>
|
||||
<a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a>
|
||||
<a href="https://io.net/" target="_blank">
|
||||
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
---
|
||||
|
||||
You can also access the AI-generated DeepWiki:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
## 🙏 Special Thanks
|
||||
|
||||
## ✨ Key Features
|
||||
<p align="center">
|
||||
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
|
||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
|
||||
<p align="center">
|
||||
<strong>Thanks to <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> for providing free open-source development license for this project</strong>
|
||||
</p>
|
||||
|
||||
1. 🎨 Brand new UI interface
|
||||
2. 🌍 Multi-language support
|
||||
3. 💰 Online recharge functionality, currently supports EPay and Stripe
|
||||
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 Compatible with the original One API database
|
||||
6. 💵 Support for pay-per-use model pricing
|
||||
7. ⚖️ Support for weighted random channel selection
|
||||
8. 📈 Data dashboard (console)
|
||||
9. 🔒 Token grouping and model restrictions
|
||||
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||
1. OpenAI o-series models
|
||||
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
||||
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
||||
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
||||
2. Claude thinking models
|
||||
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
||||
17. 🔄 Thinking-to-content functionality
|
||||
18. 🔄 Model rate limiting for users
|
||||
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
||||
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
||||
3. Supported channels:
|
||||
- [x] OpenAI
|
||||
- [x] Azure
|
||||
- [x] DeepSeek
|
||||
- [x] Claude
|
||||
---
|
||||
|
||||
## Model Support
|
||||
## 🚀 Quick Start
|
||||
|
||||
This version supports multiple models, please refer to [API Documentation-Relay Interface](https://docs.newapi.pro/api) for details:
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
1. Third-party models **gpts** (gpt-4-gizmo-*)
|
||||
2. Third-party channel [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [API Documentation](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||
3. Third-party channel [Suno API](https://github.com/Suno-API/Suno-API) interface, [API Documentation](https://docs.newapi.pro/api/suno-music)
|
||||
4. Custom channels, supporting full call address input
|
||||
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify, currently only supports chatflow
|
||||
9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api)
|
||||
|
||||
## Environment Variable Configuration
|
||||
|
||||
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
|
||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
||||
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
||||
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
|
||||
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
||||
- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false`
|
||||
|
||||
## Deployment
|
||||
|
||||
For detailed deployment guides, please refer to [Installation Guide-Deployment Methods](https://docs.newapi.pro/installation):
|
||||
|
||||
> [!TIP]
|
||||
> Latest Docker image: `calciumion/new-api:latest`
|
||||
|
||||
### Multi-machine Deployment Considerations
|
||||
- Environment variable `SESSION_SECRET` must be set, otherwise login status will be inconsistent across multiple machines
|
||||
- If sharing Redis, `CRYPTO_SECRET` must be set, otherwise Redis content cannot be accessed across multiple machines
|
||||
|
||||
### Deployment Requirements
|
||||
- Local database (default): SQLite (Docker deployment must mount the `/data` directory)
|
||||
- Remote database: MySQL version >= 5.7.8, PgSQL version >= 9.6
|
||||
|
||||
### Deployment Methods
|
||||
|
||||
#### Using BaoTa Panel Docker Feature
|
||||
Install BaoTa Panel (version **9.2.0** or above), find **New-API** in the application store and install it.
|
||||
[Tutorial with images](./docs/BT.md)
|
||||
|
||||
#### Using Docker Compose (Recommended)
|
||||
```shell
|
||||
# Download the project
|
||||
git clone https://github.com/Calcium-Ion/new-api.git
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
# Edit docker-compose.yml as needed
|
||||
# Start
|
||||
|
||||
# Edit docker-compose.yml configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start the service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Using Docker Image Directly
|
||||
```shell
|
||||
# Using SQLite
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
<details>
|
||||
<summary><strong>Using Docker Commands</strong></summary>
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# Using SQLite (default)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# Using MySQL
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
## Channel Retry and Cache
|
||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
|
||||
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
|
||||
|
||||
### Cache Configuration Method
|
||||
1. `REDIS_CONN_STRING`: Set Redis as cache
|
||||
2. `MEMORY_CACHE_ENABLED`: Enable memory cache (no need to set manually if Redis is set)
|
||||
</details>
|
||||
|
||||
## API Documentation
|
||||
---
|
||||
|
||||
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
|
||||
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation)
|
||||
|
||||
## Related Projects
|
||||
- [One API](https://github.com/songquanpeng/one-api): Original project
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
||||
---
|
||||
|
||||
Other projects based on New API:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
||||
## 📚 Documentation
|
||||
|
||||
## Help and Support
|
||||
<div align="center">
|
||||
|
||||
If you have any questions, please refer to [Help and Support](https://docs.newapi.pro/support):
|
||||
- [Community Interaction](https://docs.newapi.pro/support/community-interaction)
|
||||
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
|
||||
- [FAQ](https://docs.newapi.pro/support/faq)
|
||||
### 📖 [Official Documentation](https://docs.newapi.pro/) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**Quick Navigation:**
|
||||
|
||||
| Category | Link |
|
||||
|------|------|
|
||||
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) |
|
||||
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) |
|
||||
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) |
|
||||
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction)
|
||||
|
||||
### 🎨 Core Functions
|
||||
|
||||
| Feature | Description |
|
||||
|------|------|
|
||||
| 🎨 New UI | Modern user interface design |
|
||||
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
|
||||
| 🔄 Data Compatibility | Fully compatible with the original One API database |
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Payment and Billing
|
||||
|
||||
- ✅ Online recharge (EPay, Stripe)
|
||||
- ✅ Pay-per-use model pricing
|
||||
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||
- ✅ Flexible billing policy configuration
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
- 😈 Discord authorization login
|
||||
- 🤖 LinuxDO authorization login
|
||||
- 📱 Telegram authorization login
|
||||
- 🔑 OIDC unified authentication
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
**API Format Support:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
|
||||
|
||||
**Intelligent Routing:**
|
||||
- ⚖️ Channel weighted random
|
||||
- 🔄 Automatic retry on failure
|
||||
- 🚦 User-level model rate limiting
|
||||
|
||||
**Format Conversion:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 Thinking-to-content functionality
|
||||
|
||||
**Reasoning Effort Support:**
|
||||
|
||||
<details>
|
||||
<summary>View detailed configuration</summary>
|
||||
|
||||
**OpenAI series models:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude thinking models:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
|
||||
|
||||
**Google Gemini series models:**
|
||||
- `gemini-2.5-flash-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-flash-nothinking` - Disable thinking mode
|
||||
- `gemini-2.5-pro-thinking` - Enable thinking mode
|
||||
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
| 🤖 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) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom | Supports complete call address | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
<details>
|
||||
<summary>View complete interface list</summary>
|
||||
|
||||
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio)
|
||||
- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video)
|
||||
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
|
||||
- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
> [!TIP]
|
||||
> **Latest Docker image:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 Deployment Requirements
|
||||
|
||||
| Component | Requirement |
|
||||
|------|------|
|
||||
| **Local database** | SQLite (Docker must mount `/data` directory)|
|
||||
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
|
||||
| **Container engine** | Docker / Docker Compose |
|
||||
|
||||
### ⚙️ Environment Variable Configuration
|
||||
|
||||
<details>
|
||||
<summary>Common environment variable configuration</summary>
|
||||
|
||||
| Variable Name | Description | Default Value |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
|
||||
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
|
||||
| `SQL_DSN` | Database connection string | - |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | - |
|
||||
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
|
||||
|
||||
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 Deployment Methods
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Edit configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Start service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 2: Docker Commands</strong></summary>
|
||||
|
||||
**Using SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
**Using MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
> **💡 Path explanation:**
|
||||
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
|
||||
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Method 3: BaoTa Panel</strong></summary>
|
||||
|
||||
1. Install BaoTa Panel (≥ 9.2.0 version)
|
||||
2. Search for **New-API** in the application store
|
||||
3. One-click installation
|
||||
|
||||
📖 [Tutorial with images](./docs/BT.md)
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ Multi-machine Deployment Considerations
|
||||
|
||||
> [!WARNING]
|
||||
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
|
||||
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
|
||||
|
||||
### 🔄 Channel Retry and Cache
|
||||
|
||||
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
|
||||
|
||||
**Cache configuration:**
|
||||
- `REDIS_CONN_STRING`: Redis cache (recommended)
|
||||
- `MEMORY_CACHE_ENABLED`: Memory cache
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Projects
|
||||
|
||||
### Upstream Projects
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [One API](https://github.com/songquanpeng/one-api) | Original project base |
|
||||
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
|
||||
|
||||
### Supporting Tools
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||
|
||||
---
|
||||
|
||||
## 💬 Help Support
|
||||
|
||||
### 📖 Documentation Resources
|
||||
|
||||
| Resource | Link |
|
||||
|------|------|
|
||||
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
|
||||
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) |
|
||||
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) |
|
||||
|
||||
### 🤝 Contribution Guide
|
||||
|
||||
Welcome all forms of contribution!
|
||||
|
||||
- 🐛 Report Bugs
|
||||
- 💡 Propose New Features
|
||||
- 📝 Improve Documentation
|
||||
- 🔧 Submit Code
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 💖 Thank you for using New API
|
||||
|
||||
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)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
</div>
|
||||
|
||||
536
README.fr.md
536
README.fr.md
@@ -1,19 +1,17 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
# New API
|
||||
|
||||
🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
|
||||
🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA**
|
||||
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<p align="center">
|
||||
<a href="./README.md">中文</a> |
|
||||
<a href="./README.en.md">English</a> |
|
||||
<strong>Français</strong> |
|
||||
<a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||
@@ -32,194 +30,412 @@
|
||||
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-démarrage-rapide">Démarrage rapide</a> •
|
||||
<a href="#-fonctionnalités-clés">Fonctionnalités clés</a> •
|
||||
<a href="#-déploiement">Déploiement</a> •
|
||||
<a href="#-documentation">Documentation</a> •
|
||||
<a href="#-aide-support">Aide</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 📝 Description du projet
|
||||
|
||||
> [!NOTE]
|
||||
> [!NOTE]
|
||||
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [!IMPORTANT]
|
||||
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
||||
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
|
||||
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
|
||||
|
||||
<h2>🤝 Partenaires de confiance</h2>
|
||||
<p id="premium-sponsors"> </p>
|
||||
<p align="center"><strong>Sans ordre particulier</strong></p>
|
||||
---
|
||||
|
||||
## 🤝 Partenaires de confiance
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||
/></a>
|
||||
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
|
||||
/></a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
||||
/></a>
|
||||
<a href="https://www.aliyun.com/" target=_blank><img
|
||||
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||
/></a>
|
||||
<a href="https://io.net/" target=_blank><img
|
||||
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||
/></a>
|
||||
<em>Sans ordre particulier</em>
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
## 📚 Documentation
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target="_blank">
|
||||
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
|
||||
</a>
|
||||
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
|
||||
</a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||
</a>
|
||||
<a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a>
|
||||
<a href="https://io.net/" target="_blank">
|
||||
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
---
|
||||
|
||||
Vous pouvez également accéder au DeepWiki généré par l'IA :
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
## 🙏 Remerciements spéciaux
|
||||
|
||||
## ✨ Fonctionnalités clés
|
||||
<p align="center">
|
||||
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
|
||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
|
||||
<p align="center">
|
||||
<strong>Merci à <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> pour avoir fourni une licence de développement open-source gratuite pour ce projet</strong>
|
||||
</p>
|
||||
|
||||
1. 🎨 Nouvelle interface utilisateur
|
||||
2. 🌍 Prise en charge multilingue
|
||||
3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe
|
||||
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 Compatible avec la base de données originale de One API
|
||||
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
|
||||
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
|
||||
8. 📈 Tableau de bord des données (console)
|
||||
9. 🔒 Regroupement de jetons et restrictions de modèles
|
||||
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||
1. Modèles de la série o d'OpenAI
|
||||
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
||||
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
||||
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
||||
2. Modèles de pensée de Claude
|
||||
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||
17. 🔄 Fonctionnalité de la pensée au contenu
|
||||
18. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||
3. Canaux pris en charge :
|
||||
- [x] OpenAI
|
||||
- [x] Azure
|
||||
- [x] DeepSeek
|
||||
- [x] Claude
|
||||
---
|
||||
|
||||
## Prise en charge des modèles
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
|
||||
### Utilisation de Docker Compose (recommandé)
|
||||
|
||||
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
|
||||
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
|
||||
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
|
||||
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify, ne prend actuellement en charge que chatflow
|
||||
9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api)
|
||||
|
||||
## Configuration des variables d'environnement
|
||||
|
||||
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
||||
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
||||
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
||||
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
||||
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
|
||||
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
||||
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
|
||||
|
||||
## Déploiement
|
||||
|
||||
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
|
||||
|
||||
> [!TIP]
|
||||
> Dernière image Docker : `calciumion/new-api:latest`
|
||||
|
||||
### Considérations sur le déploiement multi-machines
|
||||
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
|
||||
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
|
||||
|
||||
### Exigences de déploiement
|
||||
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
|
||||
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
|
||||
|
||||
### Méthodes de déploiement
|
||||
|
||||
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
|
||||
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
|
||||
[Tutoriel avec des images](./docs/BT.md)
|
||||
|
||||
#### Utilisation de Docker Compose (recommandé)
|
||||
```shell
|
||||
# Télécharger le projet
|
||||
git clone https://github.com/Calcium-Ion/new-api.git
|
||||
```bash
|
||||
# Cloner le projet
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
# Modifier docker-compose.yml si nécessaire
|
||||
# Démarrer
|
||||
|
||||
# Modifier la configuration docker-compose.yml
|
||||
nano docker-compose.yml
|
||||
|
||||
# Démarrer le service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Utilisation directe de l'image Docker
|
||||
```shell
|
||||
# Utilisation de SQLite
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
<details>
|
||||
<summary><strong>Utilisation des commandes Docker</strong></summary>
|
||||
|
||||
```bash
|
||||
# Tirer la dernière image
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# Utilisation de SQLite (par défaut)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# Utilisation de MySQL
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
## Nouvelle tentative de canal et cache
|
||||
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
|
||||
> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data`
|
||||
|
||||
### Méthode de configuration du cache
|
||||
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
||||
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
|
||||
</details>
|
||||
|
||||
## Documentation de l'API
|
||||
---
|
||||
|
||||
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
||||
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
|
||||
|
||||
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation)
|
||||
|
||||
## Projets connexes
|
||||
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||
---
|
||||
|
||||
Autres projets basés sur New API :
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||
## 📚 Documentation
|
||||
|
||||
## Aide et support
|
||||
<div align="center">
|
||||
|
||||
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
|
||||
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
|
||||
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
|
||||
- [FAQ](https://docs.newapi.pro/support/faq)
|
||||
### 📖 [Documentation officielle](https://docs.newapi.pro/) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**Navigation rapide:**
|
||||
|
||||
| Catégorie | Lien |
|
||||
|------|------|
|
||||
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) |
|
||||
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) |
|
||||
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) |
|
||||
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités clés
|
||||
|
||||
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) |
|
||||
|
||||
### 🎨 Fonctions principales
|
||||
|
||||
| Fonctionnalité | Description |
|
||||
|------|------|
|
||||
| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
|
||||
| 🌍 Multilingue | Prend en charge le chinois, l'anglais, le français, le japonais |
|
||||
| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
|
||||
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
|
||||
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
|
||||
|
||||
### 💰 Paiement et facturation
|
||||
|
||||
- ✅ Recharge en ligne (EPay, Stripe)
|
||||
- ✅ Tarification des modèles de paiement à l'utilisation
|
||||
- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
|
||||
- ✅ Configuration flexible des politiques de facturation
|
||||
|
||||
### 🔐 Autorisation et sécurité
|
||||
|
||||
- 🤖 Connexion par autorisation LinuxDO
|
||||
- 📱 Connexion par autorisation Telegram
|
||||
- 🔑 Authentification unifiée OIDC
|
||||
|
||||
### 🚀 Fonctionnalités avancées
|
||||
|
||||
**Prise en charge des formats d'API:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
|
||||
|
||||
**Routage intelligent:**
|
||||
- ⚖️ Sélection aléatoire pondérée des canaux
|
||||
- 🔄 Nouvelle tentative automatique en cas d'échec
|
||||
- 🚦 Limitation du débit du modèle pour les utilisateurs
|
||||
|
||||
**Conversion de format:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 Fonctionnalité de la pensée au contenu
|
||||
|
||||
**Prise en charge de l'effort de raisonnement:**
|
||||
|
||||
<details>
|
||||
<summary>Voir la configuration détaillée</summary>
|
||||
|
||||
**Modèles de la série o d'OpenAI:**
|
||||
- `o3-mini-high` - Effort de raisonnement élevé
|
||||
- `o3-mini-medium` - Effort de raisonnement moyen
|
||||
- `o3-mini-low` - Effort de raisonnement faible
|
||||
|
||||
**Modèles de pensée de Claude:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
|
||||
|
||||
**Modèles de la série Google Gemini:**
|
||||
- `gemini-2.5-flash-thinking` - Activer le mode de pensée
|
||||
- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
|
||||
- `gemini-2.5-pro-thinking` - Activer le mode de pensée
|
||||
- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Prise en charge des modèles
|
||||
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api)
|
||||
|
||||
| Type de modèle | Description | Documentation |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
|
||||
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
|
||||
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
|
||||
| 🔧 Dify | Mode ChatFlow | - |
|
||||
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
|
||||
|
||||
### 📡 Interfaces prises en charge
|
||||
|
||||
<details>
|
||||
<summary>Voir la liste complète des interfaces</summary>
|
||||
|
||||
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio)
|
||||
- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video)
|
||||
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
|
||||
- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Déploiement
|
||||
|
||||
> [!TIP]
|
||||
> **Dernière image Docker:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 Exigences de déploiement
|
||||
|
||||
| Composant | Exigence |
|
||||
|------|------|
|
||||
| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
|
||||
| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
|
||||
| **Moteur de conteneur** | Docker / Docker Compose |
|
||||
|
||||
### ⚙️ Configuration des variables d'environnement
|
||||
|
||||
<details>
|
||||
<summary>Configuration courante des variables d'environnement</summary>
|
||||
|
||||
| Nom de variable | Description | Valeur par défaut |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) |
|
||||
| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - |
|
||||
| `SQL_DSN` | Chaine de connexion à la base de données | - |
|
||||
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
|
||||
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
|
||||
|
||||
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 Méthodes de déploiement
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
|
||||
|
||||
```bash
|
||||
# Cloner le projet
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# Modifier la configuration
|
||||
nano docker-compose.yml
|
||||
|
||||
# Démarrer le service
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 2: Commandes Docker</strong></summary>
|
||||
|
||||
**Utilisation de SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
**Utilisation de MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
> **💡 Explication du chemin:**
|
||||
> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel
|
||||
> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
|
||||
|
||||
1. Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
|
||||
2. Recherchez **New-API** dans le magasin d'applications et installez-le.
|
||||
|
||||
📖 [Tutoriel avec des images](./docs/BT.md)
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ Considérations sur le déploiement multi-machines
|
||||
|
||||
> [!WARNING]
|
||||
> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines
|
||||
> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées
|
||||
|
||||
### 🔄 Nouvelle tentative de canal et cache
|
||||
|
||||
**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
|
||||
|
||||
**Configuration du cache:**
|
||||
- `REDIS_CONN_STRING`: Cache Redis (recommandé)
|
||||
- `MEMORY_CACHE_ENABLED`: Cache mémoire
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Projets connexes
|
||||
|
||||
### Projets en amont
|
||||
|
||||
| Projet | Description |
|
||||
|------|------|
|
||||
| [One API](https://github.com/songquanpeng/one-api) | Base du projet original |
|
||||
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Prise en charge de l'interface Midjourney |
|
||||
|
||||
### Outils d'accompagnement
|
||||
|
||||
| Projet | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
|
||||
|
||||
---
|
||||
|
||||
## 💬 Aide et support
|
||||
|
||||
### 📖 Ressources de documentation
|
||||
|
||||
| Ressource | Lien |
|
||||
|------|------|
|
||||
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
|
||||
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) |
|
||||
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) |
|
||||
|
||||
### 🤝 Guide de contribution
|
||||
|
||||
Bienvenue à toutes les formes de contribution!
|
||||
|
||||
- 🐛 Signaler des bogues
|
||||
- 💡 Proposer de nouvelles fonctionnalités
|
||||
- 📝 Améliorer la documentation
|
||||
- 🔧 Soumettre du code
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Historique des étoiles
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 💖 Merci d'utiliser New API
|
||||
|
||||
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile!
|
||||
|
||||
**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Construit avec ❤️ par QuantumNous</sub>
|
||||
|
||||
</div>
|
||||
542
README.ja.md
542
README.ja.md
@@ -1,19 +1,17 @@
|
||||
<p align="right">
|
||||
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
# New API
|
||||
|
||||
🍥次世代大規模モデルゲートウェイとAI資産管理システム
|
||||
🍥 **次世代大規模モデルゲートウェイとAI資産管理システム**
|
||||
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<p align="center">
|
||||
<a href="./README.md">中文</a> |
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<strong>日本語</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||
@@ -32,6 +30,21 @@
|
||||
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-クイックスタート">クイックスタート</a> •
|
||||
<a href="#-主な機能">主な機能</a> •
|
||||
<a href="#-デプロイ">デプロイ</a> •
|
||||
<a href="#-ドキュメント">ドキュメント</a> •
|
||||
<a href="#-ヘルプサポート">ヘルプ</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 📝 プロジェクト説明
|
||||
@@ -44,183 +57,394 @@
|
||||
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
|
||||
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
|
||||
|
||||
<h2>🤝 信頼できるパートナー</h2>
|
||||
<p id="premium-sponsors"> </p>
|
||||
<p align="center"><strong>順不同</strong></p>
|
||||
---
|
||||
|
||||
## 🤝 信頼できるパートナー
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||
/></a>
|
||||
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||
src="./docs/images/pku.png" alt="北京大学" height="120"
|
||||
/></a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||
src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
|
||||
/></a>
|
||||
<a href="https://www.aliyun.com/" target=_blank><img
|
||||
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||
/></a>
|
||||
<a href="https://io.net/" target=_blank><img
|
||||
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||
/></a>
|
||||
<em>順不同</em>
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
## 📚 ドキュメント
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target="_blank">
|
||||
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
|
||||
</a>
|
||||
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
|
||||
</a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
|
||||
</a>
|
||||
<a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||
</a>
|
||||
<a href="https://io.net/" target="_blank">
|
||||
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
---
|
||||
|
||||
AIが生成したDeepWikiにもアクセスできます:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
## 🙏 特別な感謝
|
||||
|
||||
## ✨ 主な機能
|
||||
<p align="center">
|
||||
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
|
||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください:
|
||||
<p align="center">
|
||||
<strong>感謝 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> が本プロジェクトに無料のオープンソース開発ライセンスを提供してくれたことに感謝します</strong>
|
||||
</p>
|
||||
|
||||
1. 🎨 全く新しいUIインターフェース
|
||||
2. 🌍 多言語サポート
|
||||
3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート
|
||||
4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携)
|
||||
5. 🔄 オリジナルのOne APIデータベースと互換性あり
|
||||
6. 💵 モデルの従量課金をサポート
|
||||
7. ⚖️ チャネルの重み付けランダムをサポート
|
||||
8. 📈 データダッシュボード(コンソール)
|
||||
9. 🔒 トークングループ化、モデル制限
|
||||
10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC)
|
||||
11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
1. OpenAI oシリーズモデル
|
||||
- `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`)
|
||||
- `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`)
|
||||
- `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`)
|
||||
2. Claude思考モデル
|
||||
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`)
|
||||
17. 🔄 思考からコンテンツへの機能
|
||||
18. 🔄 ユーザーに対するモデルレート制限機能
|
||||
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定
|
||||
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
|
||||
3. サポートされているチャネル:
|
||||
- [x] OpenAI
|
||||
- [x] Azure
|
||||
- [x] DeepSeek
|
||||
- [x] Claude
|
||||
---
|
||||
|
||||
## モデルサポート
|
||||
## 🚀 クイックスタート
|
||||
|
||||
このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください:
|
||||
### Docker Composeを使用(推奨)
|
||||
|
||||
1. サードパーティモデル **gpts**(gpt-4-gizmo-*)
|
||||
2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||
3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music)
|
||||
4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート
|
||||
5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify、現在はchatflowのみをサポート
|
||||
9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください
|
||||
|
||||
## 環境変数設定
|
||||
|
||||
詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください:
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false`
|
||||
- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒
|
||||
- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true`
|
||||
- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true`
|
||||
- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20`
|
||||
- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用
|
||||
- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分
|
||||
- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2`
|
||||
- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false`
|
||||
|
||||
## デプロイ
|
||||
|
||||
詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください:
|
||||
|
||||
> [!TIP]
|
||||
> 最新のDockerイメージ:`calciumion/new-api:latest`
|
||||
|
||||
### マルチマシンデプロイの注意事項
|
||||
- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
|
||||
- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません
|
||||
|
||||
### デプロイ要件
|
||||
- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります)
|
||||
- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6
|
||||
|
||||
### デプロイ方法
|
||||
|
||||
#### 宝塔パネルのDocker機能を使用してデプロイ
|
||||
宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。
|
||||
[画像付きチュートリアル](./docs/BT.md)
|
||||
|
||||
#### Docker Composeを使用してデプロイ(推奨)
|
||||
```shell
|
||||
# プロジェクトをダウンロード
|
||||
git clone https://github.com/Calcium-Ion/new-api.git
|
||||
```bash
|
||||
# プロジェクトをクローン
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
# 必要に応じてdocker-compose.ymlを編集
|
||||
# 起動
|
||||
|
||||
# docker-compose.yml 設定を編集
|
||||
nano docker-compose.yml
|
||||
|
||||
# サービスを起動
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Dockerイメージを直接使用
|
||||
```shell
|
||||
# SQLiteを使用
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
<details>
|
||||
<summary><strong>Dockerコマンドを使用</strong></summary>
|
||||
|
||||
```bash
|
||||
# 最新のイメージをプル
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# SQLiteを使用(デフォルト)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# MySQLを使用
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
## チャネルリトライとキャッシュ
|
||||
チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。
|
||||
> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data`
|
||||
|
||||
### キャッシュ設定方法
|
||||
1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定
|
||||
2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要)
|
||||
</details>
|
||||
|
||||
## APIドキュメント
|
||||
---
|
||||
|
||||
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
|
||||
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
|
||||
|
||||
- [チャットインターフェース(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [レスポンスインターフェース(Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。
|
||||
|
||||
## 関連プロジェクト
|
||||
- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会
|
||||
---
|
||||
|
||||
New APIベースのその他のプロジェクト:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版
|
||||
## 📚 ドキュメント
|
||||
|
||||
## ヘルプサポート
|
||||
<div align="center">
|
||||
|
||||
問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください:
|
||||
- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction)
|
||||
- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues)
|
||||
- [よくある質問](https://docs.newapi.pro/support/faq)
|
||||
### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
## 🌟 Star History
|
||||
</div>
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
**クイックナビゲーション:**
|
||||
|
||||
| カテゴリ | リンク |
|
||||
|------|------|
|
||||
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) |
|
||||
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) |
|
||||
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) |
|
||||
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください。
|
||||
|
||||
### 🎨 コア機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
|
||||
| 🌍 多言語 | 中国語、英語、フランス語、日本語をサポート |
|
||||
| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
|
||||
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
|
||||
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
|
||||
|
||||
### 💰 支払いと課金
|
||||
|
||||
- ✅ オンライン充電(EPay、Stripe)
|
||||
- ✅ モデルの従量課金
|
||||
- ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
|
||||
- ✅ 柔軟な課金ポリシー設定
|
||||
|
||||
### 🔐 認証とセキュリティ
|
||||
|
||||
- 🤖 LinuxDO認証ログイン
|
||||
- 📱 Telegram認証ログイン
|
||||
- 🔑 OIDC統一認証
|
||||
|
||||
|
||||
|
||||
### 🚀 高度な機能
|
||||
|
||||
**APIフォーマットサポート:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(Azureを含む)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina)
|
||||
|
||||
**インテリジェントルーティング:**
|
||||
- ⚖️ チャネル重み付けランダム
|
||||
- 🔄 失敗自動リトライ
|
||||
- 🚦 ユーザーレベルモデルレート制限
|
||||
|
||||
**フォーマット変換:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 思考からコンテンツへの機能
|
||||
|
||||
**Reasoning Effort サポート:**
|
||||
|
||||
<details>
|
||||
<summary>詳細設定を表示</summary>
|
||||
|
||||
**OpenAIシリーズモデル:**
|
||||
- `o3-mini-high` - 高思考努力
|
||||
- `o3-mini-medium` - 中思考努力
|
||||
- `o3-mini-low` - 低思考努力
|
||||
- `gpt-5-high` - 高思考努力
|
||||
- `gpt-5-medium` - 中思考努力
|
||||
- `gpt-5-low` - 低思考努力
|
||||
|
||||
**Claude思考モデル:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
|
||||
|
||||
**Google Geminiシリーズモデル:**
|
||||
- `gemini-2.5-flash-thinking` - 思考モードを有効にする
|
||||
- `gemini-2.5-flash-nothinking` - 思考モードを無効にする
|
||||
- `gemini-2.5-pro-thinking` - 思考モードを有効にする
|
||||
- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 モデルサポート
|
||||
|
||||
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api)
|
||||
|
||||
| モデルタイプ | 説明 | ドキュメント |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) |
|
||||
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
|
||||
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) |
|
||||
| 🔧 Dify | ChatFlowモード | - |
|
||||
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
|
||||
|
||||
### 📡 サポートされているインターフェース
|
||||
|
||||
<details>
|
||||
<summary>完全なインターフェースリストを表示</summary>
|
||||
|
||||
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio)
|
||||
- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video)
|
||||
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
|
||||
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 デプロイ
|
||||
|
||||
> [!TIP]
|
||||
> **最新のDockerイメージ:** `calciumion/new-api:latest`
|
||||
|
||||
### 📋 デプロイ要件
|
||||
|
||||
| コンポーネント | 要件 |
|
||||
|------|------|
|
||||
| **ローカルデータベース** | SQLite(Dockerは `/data` ディレクトリをマウントする必要があります)|
|
||||
| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
|
||||
| **コンテナエンジン** | Docker / Docker Compose |
|
||||
|
||||
### ⚙️ 環境変数設定
|
||||
|
||||
<details>
|
||||
<summary>一般的な環境変数設定</summary>
|
||||
|
||||
| 変数名 | 説明 | デフォルト値 |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - |
|
||||
| `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - |
|
||||
| `SQL_DSN** | データベース接続文字列 | - |
|
||||
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
|
||||
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
|
||||
|
||||
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 デプロイ方法
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 1: Docker Compose(推奨)</strong></summary>
|
||||
|
||||
```bash
|
||||
# プロジェクトをクローン
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 設定を編集
|
||||
nano docker-compose.yml
|
||||
|
||||
# サービスを起動
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 2: Dockerコマンド</strong></summary>
|
||||
|
||||
**SQLiteを使用:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
**MySQLを使用:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
> **💡 パス説明:**
|
||||
> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます
|
||||
> - 絶対パスを使用することもできます:`/your/custom/path:/data`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>方法 3: 宝塔パネル</strong></summary>
|
||||
|
||||
1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。
|
||||
|
||||
📖 [画像付きチュートリアル](./docs/BT.md)
|
||||
|
||||
</details>
|
||||
|
||||
### ⚠️ マルチマシンデプロイの注意事項
|
||||
|
||||
> [!WARNING]
|
||||
> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
|
||||
> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません
|
||||
|
||||
### 🔄 チャネルリトライとキャッシュ
|
||||
|
||||
**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
|
||||
|
||||
**キャッシュ設定:**
|
||||
- `REDIS_CONN_STRING`:Redisキャッシュ(推奨)
|
||||
- `MEMORY_CACHE_ENABLED`:メモリキャッシュ
|
||||
|
||||
---
|
||||
|
||||
## 🔗 関連プロジェクト
|
||||
|
||||
### 上流プロジェクト
|
||||
|
||||
| プロジェクト | 説明 |
|
||||
|------|------|
|
||||
| [One API](https://github.com/songquanpeng/one-api) | オリジナルプロジェクトベース |
|
||||
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourneyインターフェースサポート |
|
||||
|
||||
### 補助ツール
|
||||
|
||||
| プロジェクト | 説明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
|
||||
|
||||
---
|
||||
|
||||
## 💬 ヘルプサポート
|
||||
|
||||
### 📖 ドキュメントリソース
|
||||
|
||||
| リソース | リンク |
|
||||
|------|------|
|
||||
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
|
||||
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) |
|
||||
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) |
|
||||
|
||||
### 🤝 貢献ガイド
|
||||
|
||||
あらゆる形の貢献を歓迎します!
|
||||
|
||||
- 🐛 バグを報告する
|
||||
- 💡 新しい機能を提案する
|
||||
- 📝 ドキュメントを改善する
|
||||
- 🔧 コードを提出する
|
||||
|
||||
---
|
||||
|
||||
## 🌟 スター履歴
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 💖 New APIをご利用いただきありがとうございます
|
||||
|
||||
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
|
||||
|
||||
**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>❤️ で構築された QuantumNous</sub>
|
||||
|
||||
</div>
|
||||
|
||||
515
README.md
515
README.md
@@ -1,15 +1,17 @@
|
||||
<p align="right">
|
||||
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||

|
||||
|
||||
# New API
|
||||
|
||||
🍥新一代大模型网关与AI资产管理系统
|
||||
🍥 **新一代大模型网关与AI资产管理系统**
|
||||
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<p align="center">
|
||||
<strong>中文</strong> |
|
||||
<a href="./README.en.md">English</a> |
|
||||
<a href="./README.fr.md">Français</a> |
|
||||
<a href="./README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||
@@ -28,200 +30,419 @@
|
||||
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-快速开始">快速开始</a> •
|
||||
<a href="#-主要特性">主要特性</a> •
|
||||
<a href="#-部署">部署</a> •
|
||||
<a href="#-文档">文档</a> •
|
||||
<a href="#-帮助支持">帮助</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 📝 项目说明
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发
|
||||
> 本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api) 的基础上进行二次开发
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。
|
||||
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
|
||||
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
|
||||
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
|
||||
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
|
||||
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
|
||||
|
||||
---
|
||||
|
||||
## 🤝 我们信任的合作伙伴
|
||||
|
||||
<h2>🤝 我们信任的合作伙伴</h2>
|
||||
<p id="premium-sponsors"> </p>
|
||||
<p align="center"><strong>排名不分先后</strong></p>
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||
/></a>
|
||||
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||
src="./docs/images/pku.png" alt="北京大学" height="120"
|
||||
/></a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||
src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="120"
|
||||
/></a>
|
||||
<a href="https://www.aliyun.com/" target=_blank><img
|
||||
src="./docs/images/aliyun.png" alt="阿里云" height="120"
|
||||
/></a>
|
||||
<a href="https://io.net/" target=_blank><img
|
||||
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||
/></a>
|
||||
<em>排名不分先后</em>
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.cherry-ai.com/" target="_blank">
|
||||
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
|
||||
</a>
|
||||
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
|
||||
</a>
|
||||
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||
<img src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="80" />
|
||||
</a>
|
||||
<a href="https://www.aliyun.com/" target="_blank">
|
||||
<img src="./docs/images/aliyun.png" alt="阿里云" height="80" />
|
||||
</a>
|
||||
<a href="https://io.net/" target="_blank">
|
||||
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🙏 特别鸣谢
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
|
||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>感谢 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> 为本项目提供免费的开源开发许可证</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
cd new-api
|
||||
|
||||
# 编辑 docker-compose.yml 配置
|
||||
nano docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>使用 Docker 命令</strong></summary>
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像
|
||||
docker pull calciumion/new-api:latest
|
||||
|
||||
# 使用 SQLite(默认)
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
|
||||
# 使用 MySQL
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data`
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
|
||||
|
||||
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation)
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
|
||||
详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
<div align="center">
|
||||
|
||||
也可访问AI生成的DeepWiki:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
### 📖 [官方文档](https://docs.newapi.pro/) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
</div>
|
||||
|
||||
**快速导航:**
|
||||
|
||||
| 分类 | 链接 |
|
||||
|------|------|
|
||||
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) |
|
||||
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) |
|
||||
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) |
|
||||
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction):
|
||||
> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction)
|
||||
|
||||
1. 🎨 全新的UI界面
|
||||
2. 🌍 多语言支持
|
||||
3. 💰 支持在线充值功能,当前支持易支付和Stripe
|
||||
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
5. 🔄 兼容原版One API的数据库
|
||||
6. 💵 支持模型按次数收费
|
||||
7. ⚖️ 支持渠道加权随机
|
||||
8. 📈 数据看板(控制台)
|
||||
9. 🔒 令牌分组、模型限制
|
||||
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
||||
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||
1. OpenAI o系列模型
|
||||
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
||||
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
||||
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
||||
2. Claude 思考模型
|
||||
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
||||
17. 🔄 思考转内容功能
|
||||
18. 🔄 针对用户的模型限流功能
|
||||
19. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages (OpenAI格式调用Claude模型)
|
||||
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
||||
3. OpenAI Chat Completions => Gemini Chat (OpenAI格式调用Gemini模型)
|
||||
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
||||
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
||||
3. 支持的渠道:
|
||||
- [x] OpenAI
|
||||
- [x] Azure
|
||||
- [x] DeepSeek
|
||||
- [x] Claude
|
||||
### 🎨 核心功能
|
||||
|
||||
## 模型支持
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 🎨 全新 UI | 现代化的用户界面设计 |
|
||||
| 🌍 多语言 | 支持中文、英文、法语、日语 |
|
||||
| 🔄 数据兼容 | 完全兼容原版 One API 数据库 |
|
||||
| 📈 数据看板 | 可视化控制台与统计分析 |
|
||||
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
|
||||
|
||||
此版本支持多种模型,详情请参考[接口文档-中继接口](https://docs.newapi.pro/api):
|
||||
### 💰 支付与计费
|
||||
|
||||
1. 第三方模型 **gpts** (gpt-4-gizmo-*)
|
||||
2. 第三方渠道[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[接口文档](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||
3. 第三方渠道[Suno API](https://github.com/Suno-API/Suno-API)接口,[接口文档](https://docs.newapi.pro/api/suno-music)
|
||||
4. 自定义渠道,支持填入完整调用地址
|
||||
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
8. Dify,当前仅支持chatflow
|
||||
9. 更多接口请参考[接口文档](https://docs.newapi.pro/api)
|
||||
- ✅ 在线充值(易支付、Stripe)
|
||||
- ✅ 模型按次数收费
|
||||
- ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型)
|
||||
- ✅ 灵活的计费策略配置
|
||||
|
||||
## 环境变量配置
|
||||
### 🔐 授权与安全
|
||||
|
||||
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
||||
- 😈 Discord 授权登录
|
||||
- 🤖 LinuxDO 授权登录
|
||||
- 📱 Telegram 授权登录
|
||||
- 🔑 OIDC 统一认证
|
||||
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
|
||||
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
||||
- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
|
||||
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
||||
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
|
||||
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
|
||||
- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容
|
||||
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
|
||||
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
||||
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
||||
### 🚀 高级功能
|
||||
|
||||
## 部署
|
||||
**API 格式支持:**
|
||||
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
|
||||
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure)
|
||||
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina)
|
||||
|
||||
详细部署指南请参考[安装指南-部署方式](https://docs.newapi.pro/installation):
|
||||
**智能路由:**
|
||||
- ⚖️ 渠道加权随机
|
||||
- 🔄 失败自动重试
|
||||
- 🚦 用户级别模型限流
|
||||
|
||||
**格式转换:**
|
||||
- 🔄 OpenAI ⇄ Claude Messages
|
||||
- 🔄 OpenAI ⇄ Gemini Chat
|
||||
- 🔄 思考转内容功能
|
||||
|
||||
**Reasoning Effort 支持:**
|
||||
|
||||
<details>
|
||||
<summary>查看详细配置</summary>
|
||||
|
||||
**OpenAI 系列模型:**
|
||||
- `o3-mini-high` - High reasoning effort
|
||||
- `o3-mini-medium` - Medium reasoning effort
|
||||
- `o3-mini-low` - Low reasoning effort
|
||||
- `gpt-5-high` - High reasoning effort
|
||||
- `gpt-5-medium` - Medium reasoning effort
|
||||
- `gpt-5-low` - Low reasoning effort
|
||||
|
||||
**Claude 思考模型:**
|
||||
- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式
|
||||
|
||||
**Google Gemini 系列模型:**
|
||||
- `gemini-2.5-flash-thinking` - 启用思考模式
|
||||
- `gemini-2.5-flash-nothinking` - 禁用思考模式
|
||||
- `gemini-2.5-pro-thinking` - 启用思考模式
|
||||
- `gemini-2.5-pro-thinking-128` - 启用思考模式,并设置思考预算为128tokens
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤖 模型支持
|
||||
|
||||
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api)
|
||||
|
||||
| 模型类型 | 说明 | 文档 |
|
||||
|---------|------|------|
|
||||
| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - |
|
||||
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) |
|
||||
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) |
|
||||
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) |
|
||||
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自定义 | 支持完整调用地址 | - |
|
||||
|
||||
### 📡 支持的接口
|
||||
|
||||
<details>
|
||||
<summary>查看完整接口列表</summary>
|
||||
|
||||
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio)
|
||||
- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video)
|
||||
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
|
||||
- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🚢 部署
|
||||
|
||||
> [!TIP]
|
||||
> 最新版Docker镜像:`calciumion/new-api:latest`
|
||||
> **最新版 Docker 镜像:** `calciumion/new-api:latest`
|
||||
|
||||
### 多机部署注意事项
|
||||
- 必须设置环境变量 `SESSION_SECRET`,否则会导致多机部署时登录状态不一致
|
||||
- 如果公用Redis,必须设置 `CRYPTO_SECRET`,否则会导致多机部署时Redis内容无法获取
|
||||
### 📋 部署要求
|
||||
|
||||
### 部署要求
|
||||
- 本地数据库(默认):SQLite(Docker部署必须挂载`/data`目录)
|
||||
- 远程数据库:MySQL版本 >= 5.7.8,PgSQL版本 >= 9.6
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| **本地数据库** | SQLite(Docker 需挂载 `/data` 目录)|
|
||||
| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
|
||||
| **容器引擎** | Docker / Docker Compose |
|
||||
|
||||
### 部署方式
|
||||
### ⚙️ 环境变量配置
|
||||
|
||||
#### 使用宝塔面板Docker功能部署
|
||||
安装宝塔面板(**9.2.0版本**及以上),在应用商店中找到**New-API**安装即可。
|
||||
[图文教程](./docs/BT.md)
|
||||
<details>
|
||||
<summary>常用环境变量配置</summary>
|
||||
|
||||
#### 使用Docker Compose部署(推荐)
|
||||
```shell
|
||||
# 下载项目源码
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `SESSION_SECRET` | 会话密钥(多机部署必须) | - |
|
||||
| `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - |
|
||||
| `SQL_DSN` | 数据库连接字符串 | - |
|
||||
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
|
||||
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
|
||||
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
|
||||
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
|
||||
|
||||
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables)
|
||||
|
||||
</details>
|
||||
|
||||
### 🔧 部署方式
|
||||
|
||||
<details>
|
||||
<summary><strong>方式 1:Docker Compose(推荐)</strong></summary>
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/QuantumNous/new-api.git
|
||||
|
||||
# 进入项目目录
|
||||
cd new-api
|
||||
|
||||
# 根据需要编辑 docker-compose.yml 文件
|
||||
# 使用nano编辑器
|
||||
# 编辑配置
|
||||
nano docker-compose.yml
|
||||
# 或使用vim编辑器
|
||||
# vim docker-compose.yml
|
||||
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 直接使用Docker镜像
|
||||
```shell
|
||||
# 使用SQLite
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
</details>
|
||||
|
||||
# 使用MySQL
|
||||
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||
<details>
|
||||
<summary><strong>方式 2:Docker 命令</strong></summary>
|
||||
|
||||
**使用 SQLite:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
## 渠道重试与缓存
|
||||
渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。
|
||||
**使用 MySQL:**
|
||||
```bash
|
||||
docker run --name new-api -d --restart always \
|
||||
-p 3000:3000 \
|
||||
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-v ./data:/data \
|
||||
calciumion/new-api:latest
|
||||
```
|
||||
|
||||
### 缓存设置方法
|
||||
1. `REDIS_CONN_STRING`:设置Redis作为缓存
|
||||
2. `MEMORY_CACHE_ENABLED`:启用内存缓存(设置了Redis则无需手动设置)
|
||||
> **💡 路径说明:**
|
||||
> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹
|
||||
> - 也可使用绝对路径,如:`/your/custom/path:/data`
|
||||
|
||||
## 接口文档
|
||||
</details>
|
||||
|
||||
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
||||
<details>
|
||||
<summary><strong>方式 3:宝塔面板</strong></summary>
|
||||
|
||||
- [聊天接口(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
1. 安装宝塔面板(≥ 9.2.0 版本)
|
||||
2. 在应用商店搜索 **New-API**
|
||||
3. 一键安装
|
||||
|
||||
## 相关项目
|
||||
- [One API](https://github.com/songquanpeng/one-api):原版项目
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
|
||||
📖 [图文教程](./docs/BT.md)
|
||||
|
||||
其他基于New API的项目:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能优化版
|
||||
</details>
|
||||
|
||||
## 帮助支持
|
||||
### ⚠️ 多机部署注意事项
|
||||
|
||||
如有问题,请参考[帮助支持](https://docs.newapi.pro/support):
|
||||
- [社区交流](https://docs.newapi.pro/support/community-interaction)
|
||||
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
|
||||
- [常见问题](https://docs.newapi.pro/support/faq)
|
||||
> [!WARNING]
|
||||
> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致
|
||||
> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密
|
||||
|
||||
### 🔄 渠道重试与缓存
|
||||
|
||||
**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数`
|
||||
|
||||
**缓存配置:**
|
||||
- `REDIS_CONN_STRING`:Redis 缓存(推荐)
|
||||
- `MEMORY_CACHE_ENABLED`:内存缓存
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关项目
|
||||
|
||||
### 上游项目
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| [One API](https://github.com/songquanpeng/one-api) | 原版项目基础 |
|
||||
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney 接口支持 |
|
||||
|
||||
### 配套工具
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
|
||||
|
||||
---
|
||||
|
||||
## 💬 帮助支持
|
||||
|
||||
### 📖 文档资源
|
||||
|
||||
| 资源 | 链接 |
|
||||
|------|------|
|
||||
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
|
||||
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
|
||||
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) |
|
||||
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) |
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
欢迎各种形式的贡献!
|
||||
|
||||
- 🐛 报告 Bug
|
||||
- 💡 提出新功能
|
||||
- 📝 改进文档
|
||||
- 🔧 提交代码
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 💖 感谢使用 New API
|
||||
|
||||
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star!
|
||||
|
||||
**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
|
||||
|
||||
<sub>Built with ❤️ by QuantumNous</sub>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,10 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
||||
apiType = constant.APITypeMoonshot
|
||||
case constant.ChannelTypeSubmodel:
|
||||
apiType = constant.APITypeSubmodel
|
||||
case constant.ChannelTypeMiniMax:
|
||||
apiType = constant.APITypeMiniMax
|
||||
case constant.ChannelTypeReplicate:
|
||||
apiType = constant.APITypeReplicate
|
||||
}
|
||||
if apiType == -1 {
|
||||
return constant.APITypeOpenAI, false
|
||||
|
||||
296
common/audio.go
Normal file
296
common/audio.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/abema/go-mp4"
|
||||
"github.com/go-audio/aiff"
|
||||
"github.com/go-audio/wav"
|
||||
"github.com/jfreymuth/oggvorbis"
|
||||
"github.com/mewkiz/flac"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tcolgate/mp3"
|
||||
"github.com/yapingcat/gomedia/go-codec"
|
||||
)
|
||||
|
||||
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
|
||||
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
|
||||
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
|
||||
// 根据文件扩展名选择解析器
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
duration, err = getMP3Duration(f)
|
||||
case ".wav":
|
||||
duration, err = getWAVDuration(f)
|
||||
case ".flac":
|
||||
duration, err = getFLACDuration(f)
|
||||
case ".m4a", ".mp4":
|
||||
duration, err = getM4ADuration(f)
|
||||
case ".ogg", ".oga", ".opus":
|
||||
duration, err = getOGGDuration(f)
|
||||
if err != nil {
|
||||
duration, err = getOpusDuration(f)
|
||||
}
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
duration, err = getAIFFDuration(f)
|
||||
case ".webm":
|
||||
duration, err = getWebMDuration(f)
|
||||
case ".aac":
|
||||
duration, err = getAACDuration(f)
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported audio format: %s", ext)
|
||||
}
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
|
||||
return duration, err
|
||||
}
|
||||
|
||||
// getMP3Duration 解析 MP3 文件以获取时长。
|
||||
// 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。
|
||||
// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
|
||||
func getMP3Duration(r io.Reader) (float64, error) {
|
||||
d := mp3.NewDecoder(r)
|
||||
var f mp3.Frame
|
||||
skipped := 0
|
||||
duration := 0.0
|
||||
|
||||
for {
|
||||
if err := d.Decode(&f, &skipped); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, errors.Wrap(err, "failed to decode mp3 frame")
|
||||
}
|
||||
duration += f.Duration().Seconds()
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getWAVDuration 解析 WAV 文件头以获取时长。
|
||||
func getWAVDuration(r io.ReadSeeker) (float64, error) {
|
||||
dec := wav.NewDecoder(r)
|
||||
if !dec.IsValidFile() {
|
||||
return 0, errors.New("invalid wav file")
|
||||
}
|
||||
d, err := dec.Duration()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get wav duration")
|
||||
}
|
||||
return d.Seconds(), nil
|
||||
}
|
||||
|
||||
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
|
||||
func getFLACDuration(r io.Reader) (float64, error) {
|
||||
stream, err := flac.Parse(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to parse flac stream")
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// 时长 = 总采样数 / 采样率
|
||||
duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
|
||||
func getM4ADuration(r io.ReadSeeker) (float64, error) {
|
||||
// go-mp4 库需要 ReadSeeker 接口
|
||||
info, err := mp4.Probe(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
|
||||
}
|
||||
// 时长 = Duration / Timescale
|
||||
return float64(info.Duration) / float64(info.Timescale), nil
|
||||
}
|
||||
|
||||
// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
|
||||
func getOGGDuration(r io.ReadSeeker) (float64, error) {
|
||||
// 重置 reader 到开头
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek ogg file")
|
||||
}
|
||||
|
||||
reader, err := oggvorbis.NewReader(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
|
||||
}
|
||||
|
||||
// 计算时长 = 总采样数 / 采样率
|
||||
// 需要读取整个文件来获取总采样数
|
||||
channels := reader.Channels()
|
||||
sampleRate := reader.SampleRate()
|
||||
|
||||
// 估算方法:读取到文件结尾
|
||||
var totalSamples int64
|
||||
buf := make([]float32, 4096*channels)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read ogg samples")
|
||||
}
|
||||
totalSamples += int64(n / channels)
|
||||
}
|
||||
|
||||
duration := float64(totalSamples) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
|
||||
func getOpusDuration(r io.ReadSeeker) (float64, error) {
|
||||
// Opus 通常封装在 OGG 容器中
|
||||
// 我们需要解析 OGG 页面来获取时长信息
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek opus file")
|
||||
}
|
||||
|
||||
// 读取 OGG 页面头部
|
||||
var totalGranulePos int64
|
||||
buf := make([]byte, 27) // OGG 页面头部最小大小
|
||||
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read opus/ogg page")
|
||||
}
|
||||
if n < 27 {
|
||||
break
|
||||
}
|
||||
|
||||
// 检查 OGG 页面标识 "OggS"
|
||||
if string(buf[0:4]) != "OggS" {
|
||||
// 跳过一些字节继续寻找
|
||||
if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取 granule position (字节 6-13, 小端序)
|
||||
granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
|
||||
if granulePos > totalGranulePos {
|
||||
totalGranulePos = granulePos
|
||||
}
|
||||
|
||||
// 读取段表大小
|
||||
numSegments := int(buf[26])
|
||||
segmentTable := make([]byte, numSegments)
|
||||
if _, err := io.ReadFull(r, segmentTable); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// 计算页面数据大小并跳过
|
||||
var pageSize int
|
||||
for _, segSize := range segmentTable {
|
||||
pageSize += int(segSize)
|
||||
}
|
||||
if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Opus 的采样率固定为 48000 Hz
|
||||
duration := float64(totalGranulePos) / 48000.0
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getAIFFDuration 解析 AIFF 文件头以获取时长。
|
||||
func getAIFFDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek aiff file")
|
||||
}
|
||||
|
||||
dec := aiff.NewDecoder(r)
|
||||
if !dec.IsValidFile() {
|
||||
return 0, errors.New("invalid aiff file")
|
||||
}
|
||||
|
||||
d, err := dec.Duration()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get aiff duration")
|
||||
}
|
||||
|
||||
return d.Seconds(), nil
|
||||
}
|
||||
|
||||
// getWebMDuration 解析 WebM 文件以获取时长。
|
||||
// WebM 使用 Matroska 容器格式
|
||||
func getWebMDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek webm file")
|
||||
}
|
||||
|
||||
// WebM/Matroska 文件的解析比较复杂
|
||||
// 这里提供一个简化的实现,读取 EBML 头部
|
||||
// 对于完整的 WebM 解析,可能需要使用专门的库
|
||||
|
||||
// 简单实现:查找 Duration 元素
|
||||
// WebM Duration 的 Element ID 是 0x4489
|
||||
// 这是一个简化版本,可能不适用于所有 WebM 文件
|
||||
buf := make([]byte, 8192)
|
||||
n, err := r.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, errors.Wrap(err, "failed to read webm file")
|
||||
}
|
||||
|
||||
// 尝试查找 Duration 元素(这是一个简化的方法)
|
||||
// 实际的 WebM 解析需要完整的 EBML 解析器
|
||||
// 这里返回错误,建议使用专门的库
|
||||
if n > 0 {
|
||||
// 检查 EBML 标识
|
||||
if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
|
||||
// 这是一个有效的 EBML 文件
|
||||
// 但完整解析需要更复杂的逻辑
|
||||
return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
|
||||
}
|
||||
}
|
||||
|
||||
return 0, errors.New("failed to parse webm file")
|
||||
}
|
||||
|
||||
// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
|
||||
// 使用 gomedia 库来解析 AAC ADTS 帧
|
||||
func getAACDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek aac file")
|
||||
}
|
||||
|
||||
// 读取整个文件内容
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read aac file")
|
||||
}
|
||||
|
||||
var totalFrames int64
|
||||
var sampleRate int
|
||||
|
||||
// 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
|
||||
codec.SplitAACFrame(data, func(aac []byte) {
|
||||
// 解析 ADTS 头部以获取采样率信息
|
||||
if len(aac) >= 7 {
|
||||
// 使用 ConvertADTSToASC 来获取音频配置信息
|
||||
asc, err := codec.ConvertADTSToASC(aac)
|
||||
if err == nil && sampleRate == 0 {
|
||||
sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
|
||||
}
|
||||
totalFrames++
|
||||
}
|
||||
})
|
||||
|
||||
if sampleRate == 0 || totalFrames == 0 {
|
||||
return 0, errors.New("no valid aac frames found")
|
||||
}
|
||||
|
||||
// 每个 AAC ADTS 帧包含 1024 个采样
|
||||
totalSamples := totalFrames * 1024
|
||||
duration := float64(totalSamples) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
@@ -159,14 +159,15 @@ var (
|
||||
GlobalWebRateLimitNum int
|
||||
GlobalWebRateLimitDuration int64
|
||||
|
||||
CriticalRateLimitEnable bool
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
|
||||
UploadRateLimitNum = 10
|
||||
UploadRateLimitDuration int64 = 60
|
||||
|
||||
DownloadRateLimitNum = 10
|
||||
DownloadRateLimitDuration int64 = 60
|
||||
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
)
|
||||
|
||||
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
||||
|
||||
104
common/gin.go
104
common/gin.go
@@ -2,9 +2,12 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,7 +42,11 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
//}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, &v)
|
||||
err = Unmarshal(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||
err = parseFormData(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
|
||||
err = parseMultipartFormData(c, requestBody, v)
|
||||
} else {
|
||||
// skip for now
|
||||
// TODO: someday non json request have variant model, we will need to implementation this
|
||||
@@ -123,13 +130,13 @@ func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||
}
|
||||
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx != -1 {
|
||||
boundary = contentType[idx+9:]
|
||||
boundary, err := parseBoundary(contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
|
||||
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
|
||||
form, err := reader.ReadForm(multipartMemoryLimit())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -138,3 +145,90 @@ func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func processFormMap(formMap map[string]any, v any) error {
|
||||
jsonData, err := Marshal(formMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = Unmarshal(jsonData, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFormData(data []byte, v any) error {
|
||||
values, err := url.ParseQuery(string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formMap := make(map[string]any)
|
||||
for key, vals := range values {
|
||||
if len(vals) == 1 {
|
||||
formMap[key] = vals[0]
|
||||
} else {
|
||||
formMap[key] = vals
|
||||
}
|
||||
}
|
||||
|
||||
return processFormMap(formMap, v)
|
||||
}
|
||||
|
||||
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
boundary, err := parseBoundary(contentType)
|
||||
if err != nil {
|
||||
if errors.Is(err, errBoundaryNotFound) {
|
||||
return Unmarshal(data, v) // Fallback to JSON
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(data), boundary)
|
||||
form, err := reader.ReadForm(multipartMemoryLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
formMap := make(map[string]any)
|
||||
for key, vals := range form.Value {
|
||||
if len(vals) == 1 {
|
||||
formMap[key] = vals[0]
|
||||
} else {
|
||||
formMap[key] = vals
|
||||
}
|
||||
}
|
||||
|
||||
return processFormMap(formMap, v)
|
||||
}
|
||||
|
||||
var errBoundaryNotFound = errors.New("multipart boundary not found")
|
||||
|
||||
// parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType
|
||||
func parseBoundary(contentType string) (string, error) {
|
||||
if contentType == "" {
|
||||
return "", errBoundaryNotFound
|
||||
}
|
||||
// Boundary-UUID / boundary-------xxxxxx
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
boundary, ok := params["boundary"]
|
||||
if !ok || boundary == "" {
|
||||
return "", errBoundaryNotFound
|
||||
}
|
||||
return boundary, nil
|
||||
}
|
||||
|
||||
// multipartMemoryLimit returns the configured multipart memory limit in bytes
|
||||
func multipartMemoryLimit() int64 {
|
||||
limitMB := constant.MaxFileDownloadMB
|
||||
if limitMB <= 0 {
|
||||
limitMB = 32
|
||||
}
|
||||
return int64(limitMB) << 20
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ func printHelp() {
|
||||
func InitEnv() {
|
||||
flag.Parse()
|
||||
|
||||
envVersion := os.Getenv("VERSION")
|
||||
if envVersion != "" {
|
||||
Version = envVersion
|
||||
}
|
||||
|
||||
if *PrintVersion {
|
||||
fmt.Println(Version)
|
||||
os.Exit(0)
|
||||
@@ -99,6 +104,9 @@ func InitEnv() {
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
||||
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
||||
initConstantEnv()
|
||||
}
|
||||
|
||||
@@ -108,8 +116,9 @@ func initConstantEnv() {
|
||||
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
||||
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
||||
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
|
||||
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", true)
|
||||
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
|
||||
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
|
||||
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
||||
constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -232,10 +230,6 @@ func GetUUID() string {
|
||||
|
||||
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func init() {
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
func GenerateRandomCharsKey(length int) (string, error) {
|
||||
b := make([]byte, length)
|
||||
maxI := big.NewInt(int64(len(keyChars)))
|
||||
@@ -329,43 +323,6 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// GetAudioDuration returns the duration of an audio file in seconds.
|
||||
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
|
||||
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
|
||||
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration")
|
||||
}
|
||||
durationStr := string(bytes.TrimSpace(output))
|
||||
if durationStr == "N/A" {
|
||||
// Create a temporary output file name
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
||||
}
|
||||
tmpName := tmpFp.Name()
|
||||
// Close immediately so ffmpeg can open the file on Windows.
|
||||
_ = tmpFp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
||||
if err := ffmpegCmd.Run(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
||||
}
|
||||
|
||||
// Recalculate the duration of the new file
|
||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
||||
}
|
||||
durationStr = string(bytes.TrimSpace(output))
|
||||
}
|
||||
return strconv.ParseFloat(durationStr, 64)
|
||||
}
|
||||
|
||||
// BuildURL concatenates base and endpoint, returns the complete url string
|
||||
func BuildURL(base string, endpoint string) string {
|
||||
u, err := url.Parse(base)
|
||||
|
||||
@@ -33,5 +33,7 @@ const (
|
||||
APITypeJimeng
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeMiniMax
|
||||
APITypeReplicate
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ const (
|
||||
ChannelTypeSubmodel = 53
|
||||
ChannelTypeDoubaoVideo = 54
|
||||
ChannelTypeSora = 55
|
||||
ChannelTypeReplicate = 56
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
@@ -114,6 +115,7 @@ var ChannelBaseURLs = []string{
|
||||
"https://llm.submodel.ai", //53
|
||||
"https://ark.cn-beijing.volces.com", //54
|
||||
"https://api.openai.com", //55
|
||||
"https://api.replicate.com", //56
|
||||
}
|
||||
|
||||
var ChannelTypeNames = map[int]string{
|
||||
@@ -169,6 +171,7 @@ var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
ChannelTypeSora: "Sora",
|
||||
ChannelTypeReplicate: "Replicate",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
|
||||
@@ -46,5 +46,7 @@ const (
|
||||
ContextKeyUsingGroup ContextKey = "group"
|
||||
ContextKeyUserName ContextKey = "username"
|
||||
|
||||
ContextKeyLocalCountTokens ContextKey = "local_count_tokens"
|
||||
|
||||
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ var StreamingTimeout int
|
||||
var DifyDebug bool
|
||||
var MaxFileDownloadMB int
|
||||
var ForceStreamOption bool
|
||||
var CountToken bool
|
||||
var GetMediaToken bool
|
||||
var GetMediaTokenNotStream bool
|
||||
var UpdateTask bool
|
||||
|
||||
@@ -617,16 +617,20 @@ func TestAllChannels(c *gin.Context) {
|
||||
var autoTestChannelsOnce sync.Once
|
||||
|
||||
func AutomaticallyTestChannels() {
|
||||
// 只在Master节点定时测试渠道
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
autoTestChannelsOnce.Do(func() {
|
||||
for {
|
||||
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
|
||||
time.Sleep(10 * time.Minute)
|
||||
time.Sleep(1 * time.Minute)
|
||||
continue
|
||||
}
|
||||
for {
|
||||
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
|
||||
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
|
||||
common.SysLog("automatically testing all channels")
|
||||
_ = testAllChannels(false)
|
||||
common.SysLog("automatically channel test finished")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel/volcengine"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -91,7 +92,7 @@ func GetAllChannels(c *gin.Context) {
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -192,13 +193,29 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
|
||||
case constant.ChannelTypeVolcEngine:
|
||||
if baseURL == volcengine.DoubaoCodingPlan {
|
||||
url = fmt.Sprintf("%s/v1/models", volcengine.DoubaoCodingPlanOpenAIBaseURL)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
default:
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
|
||||
// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
if apiErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
|
||||
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
|
||||
var body []byte
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeAnthropic:
|
||||
body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key))
|
||||
@@ -271,7 +288,7 @@ func SearchChannels(c *gin.Context) {
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
}
|
||||
@@ -649,13 +666,15 @@ func DeleteDisabledChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
type ChannelTag struct {
|
||||
Tag string `json:"tag"`
|
||||
NewTag *string `json:"new_tag"`
|
||||
Priority *int64 `json:"priority"`
|
||||
Weight *uint `json:"weight"`
|
||||
ModelMapping *string `json:"model_mapping"`
|
||||
Models *string `json:"models"`
|
||||
Groups *string `json:"groups"`
|
||||
Tag string `json:"tag"`
|
||||
NewTag *string `json:"new_tag"`
|
||||
Priority *int64 `json:"priority"`
|
||||
Weight *uint `json:"weight"`
|
||||
ModelMapping *string `json:"model_mapping"`
|
||||
Models *string `json:"models"`
|
||||
Groups *string `json:"groups"`
|
||||
ParamOverride *string `json:"param_override"`
|
||||
HeaderOverride *string `json:"header_override"`
|
||||
}
|
||||
|
||||
func DisableTagChannels(c *gin.Context) {
|
||||
@@ -721,7 +740,29 @@ func EditTagChannels(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
|
||||
if channelTag.ParamOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelTag.ParamOverride = common.GetPointer[string](trimmed)
|
||||
}
|
||||
if channelTag.HeaderOverride != nil {
|
||||
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
|
||||
if trimmed != "" && !json.Valid([]byte(trimmed)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请求头覆盖必须是合法的 JSON 格式",
|
||||
})
|
||||
return
|
||||
}
|
||||
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
|
||||
}
|
||||
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -997,7 +1038,7 @@ func GetTagModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here
|
||||
channels, err := model.GetChannelsByTag(tag, false, false) // idSort=false, selectAll=false
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
|
||||
223
controller/discord.go
Normal file
223
controller/discord.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DiscordResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type DiscordUser struct {
|
||||
UID string `json:"id"`
|
||||
ID string `json:"username"`
|
||||
Name string `json:"global_name"`
|
||||
}
|
||||
|
||||
func getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("无效的参数")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("client_id", system_setting.GetDiscordSettings().ClientId)
|
||||
values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
|
||||
formData := values.Encode()
|
||||
req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var discordResponse DiscordResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&discordResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if discordResponse.AccessToken == "" {
|
||||
common.SysError("Discord 获取 Token 失败,请检查设置!")
|
||||
return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
|
||||
res2, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
common.SysError("Discord 获取用户信息失败!请检查设置!")
|
||||
return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
|
||||
}
|
||||
|
||||
var discordUser DiscordUser
|
||||
err = json.NewDecoder(res2.Body).Decode(&discordUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if discordUser.UID == "" || discordUser.ID == "" {
|
||||
common.SysError("Discord 获取用户信息为空!请检查设置!")
|
||||
return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
|
||||
}
|
||||
return &discordUser, nil
|
||||
}
|
||||
|
||||
func DiscordOAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
state := c.Query("state")
|
||||
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "state is empty or not same",
|
||||
})
|
||||
return
|
||||
}
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
DiscordBind(c)
|
||||
return
|
||||
}
|
||||
if !system_setting.GetDiscordSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
discordUser, err := getDiscordUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
DiscordId: discordUser.UID,
|
||||
}
|
||||
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||
err := user.FillUserByDiscordId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
if discordUser.ID != "" {
|
||||
user.Username = discordUser.ID
|
||||
} else {
|
||||
user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
}
|
||||
if discordUser.Name != "" {
|
||||
user.DisplayName = discordUser.Name
|
||||
} else {
|
||||
user.DisplayName = "Discord User"
|
||||
}
|
||||
err := user.Insert(0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
func DiscordBind(c *gin.Context) {
|
||||
if !system_setting.GetDiscordSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
discordUser, err := getDiscordUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
DiscordId: discordUser.UID,
|
||||
}
|
||||
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 Discord 账户已被绑定",
|
||||
})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
user.Id = id.(int)
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user.DiscordId = discordUser.UID
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
@@ -27,17 +28,17 @@ func GetUserGroups(c *gin.Context) {
|
||||
userGroup := ""
|
||||
userId := c.GetInt("id")
|
||||
userGroup, _ = model.GetUserGroup(userId, false)
|
||||
for groupName, ratio := range ratio_setting.GetGroupRatioCopy() {
|
||||
userUsableGroups := service.GetUserUsableGroups(userGroup)
|
||||
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
|
||||
// UserUsableGroups contains the groups that the user can use
|
||||
userUsableGroups := setting.GetUserUsableGroups(userGroup)
|
||||
if desc, ok := userUsableGroups[groupName]; ok {
|
||||
usableGroups[groupName] = map[string]interface{}{
|
||||
"ratio": ratio,
|
||||
"ratio": service.GetUserGroupRatio(userGroup, groupName),
|
||||
"desc": desc,
|
||||
}
|
||||
}
|
||||
}
|
||||
if setting.GroupInUserUsableGroups("auto") {
|
||||
if _, ok := userUsableGroups["auto"]; ok {
|
||||
usableGroups["auto"] = map[string]interface{}{
|
||||
"ratio": "自动",
|
||||
"desc": setting.GetUsableGroupDescription("auto"),
|
||||
|
||||
@@ -84,7 +84,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
|
||||
}
|
||||
|
||||
// Get access token using Basic auth
|
||||
tokenEndpoint := "https://connect.linux.do/oauth2/token"
|
||||
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
|
||||
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||
|
||||
@@ -129,7 +129,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
|
||||
}
|
||||
|
||||
// Get user info
|
||||
userEndpoint := "https://connect.linux.do/api/user"
|
||||
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
|
||||
req, err = http.NewRequest("GET", userEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -52,6 +52,8 @@ func GetStatus(c *gin.Context) {
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
|
||||
"discord_client_id": system_setting.GetDiscordSettings().ClientId,
|
||||
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
||||
"linuxdo_client_id": common.LinuxDOClientId,
|
||||
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel/minimax"
|
||||
"github.com/QuantumNous/new-api/relay/channel/moonshot"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -109,6 +111,17 @@ func init() {
|
||||
func ListModels(c *gin.Context, modelType int) {
|
||||
userOpenAiModels := make([]dto.OpenAIModels, 0)
|
||||
|
||||
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
|
||||
if !acceptUnsetRatioModel {
|
||||
userId := c.GetInt("id")
|
||||
if userId > 0 {
|
||||
userSettings, _ := model.GetUserSetting(userId, false)
|
||||
if userSettings.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatioModel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||
if modelLimitEnable {
|
||||
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
|
||||
@@ -119,6 +132,12 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
tokenModelLimit = map[string]bool{}
|
||||
}
|
||||
for allowModel, _ := range tokenModelLimit {
|
||||
if !acceptUnsetRatioModel {
|
||||
_, _, exist := ratio_setting.GetModelRatioOrPrice(allowModel)
|
||||
if !exist {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[allowModel]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
@@ -149,7 +168,7 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
var models []string
|
||||
if tokenGroup == "auto" {
|
||||
for _, autoGroup := range setting.AutoGroups {
|
||||
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
|
||||
groupModels := model.GetGroupEnabledModels(autoGroup)
|
||||
for _, g := range groupModels {
|
||||
if !common.StringsContains(models, g) {
|
||||
@@ -161,6 +180,12 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
models = model.GetGroupEnabledModels(group)
|
||||
}
|
||||
for _, modelName := range models {
|
||||
if !acceptUnsetRatioModel {
|
||||
_, _, exist := ratio_setting.GetModelRatioOrPrice(modelName)
|
||||
if !exist {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[modelName]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
@@ -175,6 +200,7 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch modelType {
|
||||
case constant.ChannelTypeAnthropic:
|
||||
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
|
||||
|
||||
@@ -71,6 +71,14 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
case "discord.enabled":
|
||||
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
|
||||
})
|
||||
return
|
||||
}
|
||||
case "oidc.enabled":
|
||||
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -31,7 +31,7 @@ func Playground(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
group := c.GetString("group")
|
||||
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
modelName := c.GetString("original_model")
|
||||
|
||||
userId := c.GetInt("id")
|
||||
|
||||
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,7 +30,7 @@ func GetPricing(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
usableGroup = setting.GetUserUsableGroups(group)
|
||||
usableGroup = service.GetUserUsableGroups(group)
|
||||
// check groupRatio contains usableGroup
|
||||
for group := range ratio_setting.GetGroupRatioCopy() {
|
||||
if _, ok := usableGroup[group]; !ok {
|
||||
@@ -45,7 +45,7 @@ func GetPricing(c *gin.Context) {
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||
"auto_groups": setting.AutoGroups,
|
||||
"auto_groups": service.GetUserAutoGroup(group),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
@@ -224,7 +225,7 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
|
||||
AutoBan: &autoBanInt,
|
||||
}, nil
|
||||
}
|
||||
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
|
||||
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
|
||||
if err != nil {
|
||||
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
@@ -281,7 +282,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
}
|
||||
|
||||
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
|
||||
|
||||
@@ -52,6 +52,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||
}
|
||||
info.ApiKey = cacheGetChannel.Key
|
||||
adaptor.Init(info)
|
||||
for _, taskId := range taskIds {
|
||||
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||
|
||||
@@ -51,6 +51,8 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
data := gin.H{
|
||||
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
|
||||
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
|
||||
"creem_products": setting.CreemProducts,
|
||||
"pay_methods": payMethods,
|
||||
"min_topup": operation_setting.MinTopUp,
|
||||
"stripe_min_topup": setting.StripeMinTopUp,
|
||||
|
||||
461
controller/topup_creem.go
Normal file
461
controller/topup_creem.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentMethodCreem = "creem"
|
||||
CreemSignatureHeader = "creem-signature"
|
||||
)
|
||||
|
||||
var creemAdaptor = &CreemAdaptor{}
|
||||
|
||||
// 生成HMAC-SHA256签名
|
||||
func generateCreemSignature(payload string, secret string) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write([]byte(payload))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// 验证Creem webhook签名
|
||||
func verifyCreemSignature(payload string, signature string, secret string) bool {
|
||||
if secret == "" {
|
||||
log.Printf("Creem webhook secret not set")
|
||||
if setting.CreemTestMode {
|
||||
log.Printf("Skip Creem webhook sign verify in test mode")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
expectedSignature := generateCreemSignature(payload, secret)
|
||||
return hmac.Equal([]byte(signature), []byte(expectedSignature))
|
||||
}
|
||||
|
||||
type CreemPayRequest struct {
|
||||
ProductId string `json:"product_id"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
}
|
||||
|
||||
type CreemProduct struct {
|
||||
ProductId string `json:"productId"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Quota int64 `json:"quota"`
|
||||
}
|
||||
|
||||
type CreemAdaptor struct {
|
||||
}
|
||||
|
||||
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
if req.PaymentMethod != PaymentMethodCreem {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProductId == "" {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析产品列表
|
||||
var products []CreemProduct
|
||||
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
|
||||
if err != nil {
|
||||
log.Println("解析Creem产品列表失败", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找对应的产品
|
||||
var selectedProduct *CreemProduct
|
||||
for _, product := range products {
|
||||
if product.ProductId == req.ProductId {
|
||||
selectedProduct = &product
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedProduct == nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, _ := model.GetUserById(id, false)
|
||||
|
||||
// 生成唯一的订单引用ID
|
||||
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||
|
||||
// 先创建订单记录,使用产品配置的金额和充值额度
|
||||
topUp := &model.TopUp{
|
||||
UserId: id,
|
||||
Amount: selectedProduct.Quota, // 充值额度
|
||||
Money: selectedProduct.Price, // 支付金额
|
||||
TradeNo: referenceId,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
log.Printf("创建Creem订单失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建支付链接,传入用户邮箱
|
||||
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
|
||||
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
"order_id": referenceId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func RequestCreemPay(c *gin.Context) {
|
||||
var req CreemPayRequest
|
||||
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 打印body内容
|
||||
log.Printf("creem pay request body: %s", string(bodyBytes))
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
err = c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
creemAdaptor.RequestPay(c, &req)
|
||||
}
|
||||
|
||||
// 新的Creem Webhook结构体,匹配实际的webhook数据格式
|
||||
type CreemWebhookEvent struct {
|
||||
Id string `json:"id"`
|
||||
EventType string `json:"eventType"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Object struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
RequestId string `json:"request_id"`
|
||||
Order struct {
|
||||
Object string `json:"object"`
|
||||
Id string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Product string `json:"product"`
|
||||
Amount int `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
SubTotal int `json:"sub_total"`
|
||||
TaxAmount int `json:"tax_amount"`
|
||||
AmountDue int `json:"amount_due"`
|
||||
AmountPaid int `json:"amount_paid"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Transaction string `json:"transaction"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"order"`
|
||||
Product struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Price int `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
BillingType string `json:"billing_type"`
|
||||
BillingPeriod string `json:"billing_period"`
|
||||
Status string `json:"status"`
|
||||
TaxMode string `json:"tax_mode"`
|
||||
TaxCategory string `json:"tax_category"`
|
||||
DefaultSuccessUrl *string `json:"default_success_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"product"`
|
||||
Units int `json:"units"`
|
||||
Customer struct {
|
||||
Id string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"customer"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"object"`
|
||||
}
|
||||
|
||||
// 保留旧的结构体作为兼容
|
||||
type CreemWebhookData struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
RequestId string `json:"request_id"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func CreemWebhook(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取Creem Webhook请求body失败: %v", err)
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取签名头
|
||||
signature := c.GetHeader(CreemSignatureHeader)
|
||||
|
||||
// 打印关键信息(避免输出完整敏感payload)
|
||||
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
|
||||
if setting.CreemTestMode {
|
||||
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
|
||||
} else if signature == "" {
|
||||
log.Printf("Creem Webhook缺少签名头")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
|
||||
log.Printf("Creem Webhook签名验证失败")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem Webhook签名验证成功")
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
// 解析新格式的webhook数据
|
||||
var webhookEvent CreemWebhookEvent
|
||||
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
|
||||
log.Printf("解析Creem Webhook参数失败: %v", err)
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
|
||||
|
||||
// 根据事件类型处理不同的webhook
|
||||
switch webhookEvent.EventType {
|
||||
case "checkout.completed":
|
||||
handleCheckoutCompleted(c, &webhookEvent)
|
||||
default:
|
||||
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理支付完成事件
|
||||
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// 验证订单状态
|
||||
if event.Object.Order.Status != "paid" {
|
||||
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取引用ID(这是我们创建订单时传递的request_id)
|
||||
referenceId := event.Object.RequestId
|
||||
if referenceId == "" {
|
||||
log.Println("Creem Webhook缺少request_id字段")
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录详细的支付信息
|
||||
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
|
||||
referenceId,
|
||||
event.Object.Order.Id,
|
||||
event.Object.Order.AmountPaid,
|
||||
event.Object.Order.Currency,
|
||||
event.Object.Product.Name)
|
||||
|
||||
// 查询本地订单确认存在
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Printf("Creem充值订单不存在: %s", referenceId)
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
|
||||
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
|
||||
return
|
||||
}
|
||||
|
||||
// 处理充值,传入客户邮箱和姓名信息
|
||||
customerEmail := event.Object.Customer.Email
|
||||
customerName := event.Object.Customer.Name
|
||||
|
||||
// 防护性检查,确保邮箱和姓名不为空字符串
|
||||
if customerEmail == "" {
|
||||
log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
|
||||
}
|
||||
if customerName == "" {
|
||||
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
|
||||
}
|
||||
|
||||
err := model.RechargeCreem(referenceId, customerEmail, customerName)
|
||||
if err != nil {
|
||||
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
|
||||
referenceId, topUp.Amount, topUp.Money)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
type CreemCheckoutRequest struct {
|
||||
ProductId string `json:"product_id"`
|
||||
RequestId string `json:"request_id"`
|
||||
Customer struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"customer"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type CreemCheckoutResponse struct {
|
||||
CheckoutUrl string `json:"checkout_url"`
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||
if setting.CreemApiKey == "" {
|
||||
return "", fmt.Errorf("未配置Creem API密钥")
|
||||
}
|
||||
|
||||
// 根据测试模式选择 API 端点
|
||||
apiUrl := "https://api.creem.io/v1/checkouts"
|
||||
if setting.CreemTestMode {
|
||||
apiUrl = "https://test-api.creem.io/v1/checkouts"
|
||||
log.Printf("使用Creem测试环境: %s", apiUrl)
|
||||
}
|
||||
|
||||
// 构建请求数据,确保包含用户邮箱
|
||||
requestData := CreemCheckoutRequest{
|
||||
ProductId: product.ProductId,
|
||||
RequestId: referenceId, // 这个作为订单ID传递给Creem
|
||||
Customer: struct {
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
Email: email, // 用户邮箱会在支付页面预填充
|
||||
},
|
||||
Metadata: map[string]string{
|
||||
"username": username,
|
||||
"reference_id": referenceId,
|
||||
"product_name": product.Name,
|
||||
"quota": fmt.Sprintf("%d", product.Quota),
|
||||
},
|
||||
}
|
||||
|
||||
// 序列化请求数据
|
||||
jsonData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化请求数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建 HTTP 请求
|
||||
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", setting.CreemApiKey)
|
||||
|
||||
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
|
||||
apiUrl, product.ProductId, email, referenceId)
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
|
||||
}
|
||||
// 解析响应
|
||||
var checkoutResp CreemCheckoutResponse
|
||||
err = json.Unmarshal(body, &checkoutResp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if checkoutResp.CheckoutUrl == "" {
|
||||
return "", fmt.Errorf("Creem API resp no checkout url ")
|
||||
}
|
||||
|
||||
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
|
||||
return checkoutResp.CheckoutUrl, nil
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(setting.StripePriceId),
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -452,6 +453,7 @@ func GetSelf(c *gin.Context) {
|
||||
"status": user.Status,
|
||||
"email": user.Email,
|
||||
"github_id": user.GitHubId,
|
||||
"discord_id": user.DiscordId,
|
||||
"oidc_id": user.OidcId,
|
||||
"wechat_id": user.WeChatId,
|
||||
"telegram_id": user.TelegramId,
|
||||
@@ -579,7 +581,7 @@ func GetUserModels(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
groups := setting.GetUserUsableGroups(user.Group)
|
||||
groups := service.GetUserUsableGroups(user.Group)
|
||||
var models []string
|
||||
for group := range groups {
|
||||
for _, g := range model.GetGroupEnabledModels(group) {
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
@@ -36,7 +38,7 @@ func VideoProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !exists || task == nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %s", taskID, err.Error()))
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err))
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Task not found",
|
||||
@@ -58,7 +60,7 @@ func VideoProxy(c *gin.Context) {
|
||||
|
||||
channel, err := model.CacheGetChannel(task.ChannelId)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel %d: %s", task.ChannelId, err.Error()))
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to retrieve channel information",
|
||||
@@ -71,15 +73,15 @@ func VideoProxy(c *gin.Context) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
videoURL := fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
|
||||
|
||||
var videoURL string
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, videoURL, nil)
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request for %s: %s", videoURL, err.Error()))
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to create proxy request",
|
||||
@@ -89,7 +91,52 @@ func VideoProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeGemini:
|
||||
apiKey := task.PrivateData.Key
|
||||
if apiKey == "" {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "API key not stored for task",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
videoURL, err = getGeminiVideoURL(channel, task, apiKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error()))
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to resolve Gemini video URL",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", apiKey)
|
||||
case constant.ChannelTypeAli:
|
||||
// Video URL is directly in task.FailReason
|
||||
videoURL = task.FailReason
|
||||
default:
|
||||
// Default (Sora, etc.): Use original logic
|
||||
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
|
||||
req.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
}
|
||||
|
||||
req.URL, err = url.Parse(videoURL)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to create proxy request",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
158
controller/video_proxy_gemini.go
Normal file
158
controller/video_proxy_gemini.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
)
|
||||
|
||||
func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
|
||||
if channel == nil || task == nil {
|
||||
return "", fmt.Errorf("invalid channel or task")
|
||||
}
|
||||
|
||||
if url := extractGeminiVideoURLFromTaskData(task); url != "" {
|
||||
return ensureAPIKey(url, apiKey), nil
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
|
||||
if adaptor == nil {
|
||||
return "", fmt.Errorf("gemini task adaptor not found")
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("api key not available for task")
|
||||
}
|
||||
|
||||
resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
|
||||
"task_id": task.TaskID,
|
||||
"action": task.Action,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch task failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read task response failed: %w", err)
|
||||
}
|
||||
|
||||
taskInfo, parseErr := adaptor.ParseTaskResult(body)
|
||||
if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
|
||||
return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
|
||||
}
|
||||
|
||||
if url := extractGeminiVideoURLFromPayload(body); url != "" {
|
||||
return ensureAPIKey(url, apiKey), nil
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
return "", fmt.Errorf("parse task result failed: %w", parseErr)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("gemini video url not found")
|
||||
}
|
||||
|
||||
func extractGeminiVideoURLFromTaskData(task *model.Task) string {
|
||||
if task == nil || len(task.Data) == 0 {
|
||||
return ""
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(task.Data, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return extractGeminiVideoURLFromMap(payload)
|
||||
}
|
||||
|
||||
func extractGeminiVideoURLFromPayload(body []byte) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return extractGeminiVideoURLFromMap(payload)
|
||||
}
|
||||
|
||||
func extractGeminiVideoURLFromMap(payload map[string]any) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
if uri, ok := payload["uri"].(string); ok && uri != "" {
|
||||
return uri
|
||||
}
|
||||
if resp, ok := payload["response"].(map[string]any); ok {
|
||||
if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
|
||||
return uri
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractGeminiVideoURLFromResponse(resp map[string]any) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
|
||||
if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
|
||||
return uri
|
||||
}
|
||||
}
|
||||
if videos, ok := resp["videos"].([]any); ok {
|
||||
for _, video := range videos {
|
||||
if vm, ok := video.(map[string]any); ok {
|
||||
if uri, ok := vm["uri"].(string); ok && uri != "" {
|
||||
return uri
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if uri, ok := resp["video"].(string); ok && uri != "" {
|
||||
return uri
|
||||
}
|
||||
if uri, ok := resp["uri"].(string); ok && uri != "" {
|
||||
return uri
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
|
||||
if gvr == nil {
|
||||
return ""
|
||||
}
|
||||
if samples, ok := gvr["generatedSamples"].([]any); ok {
|
||||
for _, sample := range samples {
|
||||
if sm, ok := sample.(map[string]any); ok {
|
||||
if video, ok := sm["video"].(map[string]any); ok {
|
||||
if uri, ok := video["uri"].(string); ok && uri != "" {
|
||||
return uri
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ensureAPIKey(uri, key string) string {
|
||||
if key == "" || uri == "" {
|
||||
return uri
|
||||
}
|
||||
if strings.Contains(uri, "key=") {
|
||||
return uri
|
||||
}
|
||||
if strings.Contains(uri, "?") {
|
||||
return fmt.Sprintf("%s&key=%s", uri, key)
|
||||
}
|
||||
return fmt.Sprintf("%s?key=%s", uri, key)
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
|
||||
| GET | /api/oauth/discord | 公开 | Discord 通用 OAuth 跳转 |
|
||||
| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
|
||||
| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
|
||||
| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |
|
||||
|
||||
15
dto/audio.go
15
dto/audio.go
@@ -1,17 +1,22 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AudioRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input string `json:"input"`
|
||||
Voice string `json:"voice"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input string `json:"input"`
|
||||
Voice string `json:"voice"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
StreamFormat string `json:"stream_format,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -510,11 +510,44 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
|
||||
}
|
||||
|
||||
type ClaudeUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
|
||||
// claude cache 1h
|
||||
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
|
||||
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
|
||||
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
|
||||
}
|
||||
|
||||
type ClaudeCacheCreationUsage struct {
|
||||
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
|
||||
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
|
||||
}
|
||||
|
||||
func (u *ClaudeUsage) GetCacheCreation5mTokens() int {
|
||||
if u == nil || u.CacheCreation == nil {
|
||||
return 0
|
||||
}
|
||||
return u.CacheCreation.Ephemeral5mInputTokens
|
||||
}
|
||||
|
||||
func (u *ClaudeUsage) GetCacheCreation1hTokens() int {
|
||||
if u == nil || u.CacheCreation == nil {
|
||||
return 0
|
||||
}
|
||||
return u.CacheCreation.Ephemeral1hInputTokens
|
||||
}
|
||||
|
||||
func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
if u.CacheCreationInputTokens > 0 {
|
||||
return u.CacheCreationInputTokens
|
||||
}
|
||||
return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens()
|
||||
}
|
||||
|
||||
type ClaudeServerToolUse struct {
|
||||
|
||||
@@ -141,6 +141,39 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
|
||||
type GeminiThinkingConfig struct {
|
||||
IncludeThoughts bool `json:"includeThoughts,omitempty"`
|
||||
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
|
||||
// TODO Conflict with thinkingbudget.
|
||||
ThinkingLevel json.RawMessage `json:"thinkingLevel,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows GeminiThinkingConfig to accept both snake_case and camelCase fields.
|
||||
func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiThinkingConfig
|
||||
var aux struct {
|
||||
Alias
|
||||
IncludeThoughtsSnake *bool `json:"include_thoughts,omitempty"`
|
||||
ThinkingBudgetSnake *int `json:"thinking_budget,omitempty"`
|
||||
ThinkingLevelSnake json.RawMessage `json:"thinking_level,omitempty"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*c = GeminiThinkingConfig(aux.Alias)
|
||||
|
||||
if aux.IncludeThoughtsSnake != nil {
|
||||
c.IncludeThoughts = *aux.IncludeThoughtsSnake
|
||||
}
|
||||
|
||||
if aux.ThinkingBudgetSnake != nil {
|
||||
c.ThinkingBudget = aux.ThinkingBudgetSnake
|
||||
}
|
||||
|
||||
if len(aux.ThinkingLevelSnake) > 0 {
|
||||
c.ThinkingLevel = aux.ThinkingLevelSnake
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) {
|
||||
@@ -182,8 +215,12 @@ type FunctionCall struct {
|
||||
}
|
||||
|
||||
type GeminiFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
WillContinue json.RawMessage `json:"willContinue,omitempty"`
|
||||
Scheduling json.RawMessage `json:"scheduling,omitempty"`
|
||||
Parts json.RawMessage `json:"parts,omitempty"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiPartExecutableCode struct {
|
||||
@@ -202,11 +239,15 @@ type GeminiFileData struct {
|
||||
}
|
||||
|
||||
type GeminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought bool `json:"thought,omitempty"`
|
||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought bool `json:"thought,omitempty"`
|
||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
|
||||
ThoughtSignature json.RawMessage `json:"thoughtSignature,omitempty"`
|
||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
// Optional. Media resolution for the input media.
|
||||
MediaResolution json.RawMessage `json:"mediaResolution,omitempty"`
|
||||
VideoMetadata json.RawMessage `json:"videoMetadata,omitempty"`
|
||||
FileData *GeminiFileData `json:"fileData,omitempty"`
|
||||
ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"`
|
||||
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
|
||||
|
||||
@@ -27,7 +27,8 @@ type ImageRequest struct {
|
||||
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
|
||||
PartialImages json.RawMessage `json:"partial_images,omitempty"`
|
||||
// Stream bool `json:"stream,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Image json.RawMessage `json:"image,omitempty"`
|
||||
// 用匿名参数接收额外参数
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
@@ -66,10 +66,11 @@ type GeneralOpenAIRequest struct {
|
||||
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Prediction json.RawMessage `json:"prediction,omitempty"`
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Prediction json.RawMessage `json:"prediction,omitempty"`
|
||||
// gemini
|
||||
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
|
||||
//xai
|
||||
@@ -232,10 +233,13 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
const CustomType = "custom"
|
||||
|
||||
type ToolCallRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionRequest `json:"function"`
|
||||
Function FunctionRequest `json:"function,omitempty"`
|
||||
Custom json.RawMessage `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionRequest struct {
|
||||
@@ -795,19 +799,20 @@ type OpenAIResponsesRequest struct {
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
Truncation string `json:"truncation,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
@@ -892,6 +897,12 @@ type Reasoning struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type MediaInput struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
@@ -910,7 +921,7 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
return nil
|
||||
}
|
||||
|
||||
var inputs []MediaInput
|
||||
var mediaInputs []MediaInput
|
||||
|
||||
// Try string first
|
||||
// if str, ok := common.GetJsonType(r.Input); ok {
|
||||
@@ -920,60 +931,74 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
if common.GetJsonType(r.Input) == "string" {
|
||||
var str string
|
||||
_ = common.Unmarshal(r.Input, &str)
|
||||
inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
|
||||
return inputs
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
|
||||
return mediaInputs
|
||||
}
|
||||
|
||||
// Try array of parts
|
||||
if common.GetJsonType(r.Input) == "array" {
|
||||
var array []any
|
||||
_ = common.Unmarshal(r.Input, &array)
|
||||
for _, itemAny := range array {
|
||||
// Already parsed MediaInput
|
||||
if media, ok := itemAny.(MediaInput); ok {
|
||||
inputs = append(inputs, media)
|
||||
continue
|
||||
var inputs []Input
|
||||
_ = common.Unmarshal(r.Input, &inputs)
|
||||
for _, input := range inputs {
|
||||
if common.GetJsonType(input.Content) == "string" {
|
||||
var str string
|
||||
_ = common.Unmarshal(input.Content, &str)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
|
||||
}
|
||||
// Generic map
|
||||
item, ok := itemAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeVal, ok := item["type"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typeVal {
|
||||
case "input_text":
|
||||
text, _ := item["text"].(string)
|
||||
inputs = append(inputs, MediaInput{Type: "input_text", Text: text})
|
||||
case "input_image":
|
||||
// image_url may be string or object with url field
|
||||
var imageUrl string
|
||||
switch v := item["image_url"].(type) {
|
||||
case string:
|
||||
imageUrl = v
|
||||
case map[string]any:
|
||||
if url, ok := v["url"].(string); ok {
|
||||
imageUrl = url
|
||||
|
||||
if common.GetJsonType(input.Content) == "array" {
|
||||
var array []any
|
||||
_ = common.Unmarshal(input.Content, &array)
|
||||
for _, itemAny := range array {
|
||||
// Already parsed MediaContent
|
||||
if media, ok := itemAny.(MediaInput); ok {
|
||||
mediaInputs = append(mediaInputs, media)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generic map
|
||||
item, ok := itemAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
typeVal, ok := item["type"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typeVal {
|
||||
case "input_text":
|
||||
text, _ := item["text"].(string)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: text})
|
||||
case "input_image":
|
||||
// image_url may be string or object with url field
|
||||
var imageUrl string
|
||||
switch v := item["image_url"].(type) {
|
||||
case string:
|
||||
imageUrl = v
|
||||
case map[string]any:
|
||||
if url, ok := v["url"].(string); ok {
|
||||
imageUrl = url
|
||||
}
|
||||
}
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_image", ImageUrl: imageUrl})
|
||||
case "input_file":
|
||||
// file_url may be string or object with url field
|
||||
var fileUrl string
|
||||
switch v := item["file_url"].(type) {
|
||||
case string:
|
||||
fileUrl = v
|
||||
case map[string]any:
|
||||
if url, ok := v["url"].(string); ok {
|
||||
fileUrl = url
|
||||
}
|
||||
}
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_file", FileUrl: fileUrl})
|
||||
}
|
||||
}
|
||||
inputs = append(inputs, MediaInput{Type: "input_image", ImageUrl: imageUrl})
|
||||
case "input_file":
|
||||
// file_url may be string or object with url field
|
||||
var fileUrl string
|
||||
switch v := item["file_url"].(type) {
|
||||
case string:
|
||||
fileUrl = v
|
||||
case map[string]any:
|
||||
if url, ok := v["url"].(string); ok {
|
||||
fileUrl = url
|
||||
}
|
||||
}
|
||||
inputs = append(inputs, MediaInput{Type: "input_file", FileUrl: fileUrl})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
return mediaInputs
|
||||
}
|
||||
|
||||
@@ -230,6 +230,11 @@ type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
|
||||
|
||||
// claude cache 1h
|
||||
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
|
||||
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
|
||||
|
||||
// OpenRouter Params
|
||||
Cost any `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ type OpenAIVideo struct {
|
||||
Size string `json:"size,omitempty"`
|
||||
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
|
||||
Error *OpenAIVideoError `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"meta_data,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (m *OpenAIVideo) SetProgressStr(progress string) {
|
||||
|
||||
6
electron/package-lock.json
generated
6
electron/package-lock.json
generated
@@ -2784,9 +2784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
23
go.mod
23
go.mod
@@ -5,6 +5,7 @@ go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/Calcium-Ion/go-epay v0.0.4
|
||||
github.com/abema/go-mp4 v1.4.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2
|
||||
@@ -18,28 +19,34 @@ require (
|
||||
github.com/gin-contrib/static v0.0.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/glebarez/sqlite v1.9.0
|
||||
github.com/go-audio/aiff v1.1.0
|
||||
github.com/go-audio/wav v1.1.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jfreymuth/oggvorbis v1.0.5
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mewkiz/flac v1.0.13
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/samber/lo v1.39.0
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/stripe/stripe-go/v81 v81.4.0
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
|
||||
github.com/thanhpk/randstr v1.0.6
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tiktoken-go/tokenizer v0.6.2
|
||||
golang.org/x/crypto v0.42.0
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sync v0.18.0
|
||||
gorm.io/driver/mysql v1.4.3
|
||||
gorm.io/driver/postgres v1.5.2
|
||||
gorm.io/gorm v1.25.2
|
||||
@@ -62,6 +69,8 @@ require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-audio/audio v1.0.0 // indirect
|
||||
github.com/go-audio/riff v1.0.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -73,16 +82,20 @@ require (
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
github.com/icza/bitio v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jfreymuth/vorbis v1.0.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -98,8 +111,8 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||
golang.org/x/arch v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
|
||||
60
go.sum
60
go.sum
@@ -1,5 +1,7 @@
|
||||
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
|
||||
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
|
||||
github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M=
|
||||
github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
|
||||
@@ -33,6 +35,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -67,6 +70,15 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
|
||||
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
|
||||
github.com/go-audio/aiff v1.1.0 h1:m2LYgu/2BarpF2yZnFPWtY3Tp41k0A4y51gDRZZsEuU=
|
||||
github.com/go-audio/aiff v1.1.0/go.mod h1:sDik1muYvhPiccClfri0fv6U2fyH/dy4VRWmUz0cz9Q=
|
||||
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
|
||||
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
|
||||
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
|
||||
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
|
||||
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
|
||||
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
|
||||
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
@@ -108,6 +120,7 @@ github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
@@ -118,6 +131,10 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -126,6 +143,10 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
|
||||
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
|
||||
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
|
||||
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -145,6 +166,7 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
@@ -152,10 +174,17 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
||||
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -170,6 +199,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
@@ -209,6 +240,9 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
||||
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
|
||||
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -238,6 +272,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
|
||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
@@ -245,18 +281,19 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
|
||||
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -267,14 +304,15 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
@@ -286,6 +324,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -66,8 +66,11 @@ func LogError(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerError, msg)
|
||||
}
|
||||
|
||||
func LogDebug(ctx context.Context, msg string) {
|
||||
func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
if common.DebugEnabled {
|
||||
if len(args) > 0 {
|
||||
msg = fmt.Sprintf(msg, args...)
|
||||
}
|
||||
logHelper(ctx, loggerDebug, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -266,8 +266,8 @@ func TokenAuth() func(c *gin.Context) {
|
||||
tokenGroup := token.Group
|
||||
if tokenGroup != "" {
|
||||
// check common.UserUsableGroups[userGroup]
|
||||
if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
|
||||
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup))
|
||||
return
|
||||
}
|
||||
// check group in common.GroupRatio
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
@@ -79,27 +79,28 @@ func Distribute() func(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var selectGroup string
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
// check path is /pg/chat/completions
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
|
||||
playgroundRequest := &dto.PlayGroundRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, playgroundRequest)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error())
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的playground请求, "+err.Error())
|
||||
return
|
||||
}
|
||||
if playgroundRequest.Group != "" {
|
||||
if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup {
|
||||
if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组")
|
||||
return
|
||||
}
|
||||
userGroup = playgroundRequest.Group
|
||||
usingGroup = playgroundRequest.Group
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
|
||||
}
|
||||
}
|
||||
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
|
||||
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(c, usingGroup, modelRequest.Model, 0)
|
||||
if err != nil {
|
||||
showGroup := userGroup
|
||||
if userGroup == "auto" {
|
||||
showGroup := usingGroup
|
||||
if usingGroup == "auto" {
|
||||
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
|
||||
}
|
||||
message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(distributor): %s", showGroup, modelRequest.Model, err.Error())
|
||||
@@ -112,7 +113,7 @@ func Distribute() func(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if channel == nil {
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", userGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -123,6 +124,20 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// getModelFromRequest 从请求中读取模型信息
|
||||
// 根据 Content-Type 自动处理:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
// - multipart/form-data
|
||||
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
var modelRequest ModelRequest
|
||||
err := common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
if err != nil {
|
||||
return nil, errors.New("无效的请求, " + err.Error())
|
||||
}
|
||||
return &modelRequest, nil
|
||||
}
|
||||
|
||||
func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
var modelRequest ModelRequest
|
||||
shouldSelectChannel := true
|
||||
@@ -138,7 +153,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
midjourneyRequest := dto.MidjourneyRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, &midjourneyRequest)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, false, errors.New("无效的midjourney请求, " + err.Error())
|
||||
}
|
||||
midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest)
|
||||
if mjErr != nil {
|
||||
@@ -175,23 +190,12 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
relayMode := relayconstant.RelayModeUnknown
|
||||
if c.Request.Method == http.MethodPost {
|
||||
relayMode = relayconstant.RelayModeVideoSubmit
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的video请求, " + err.Error())
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
if form != nil {
|
||||
if values, ok := form.Value["model"]; ok && len(values) > 0 {
|
||||
modelRequest.Model = values[0]
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(contentType, "application/json") {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的video请求, " + err.Error())
|
||||
}
|
||||
req, err := getModelFromRequest(c)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if req != nil {
|
||||
modelRequest.Model = req.Model
|
||||
}
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
@@ -201,10 +205,11 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
||||
relayMode := relayconstant.RelayModeUnknown
|
||||
if c.Request.Method == http.MethodPost {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
req, err := getModelFromRequest(c)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("video无效的请求, " + err.Error())
|
||||
return nil, false, err
|
||||
}
|
||||
modelRequest.Model = req.Model
|
||||
relayMode = relayconstant.RelayModeVideoSubmit
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
@@ -222,10 +227,11 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的请求, " + err.Error())
|
||||
req, err := getModelFromRequest(c)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
modelRequest.Model = req.Model
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") {
|
||||
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
|
||||
@@ -245,20 +251,31 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
|
||||
//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
modelRequest.Model = c.PostForm("model")
|
||||
contentType := c.ContentType()
|
||||
if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) {
|
||||
req, err := getModelFromRequest(c)
|
||||
if err == nil && req.Model != "" {
|
||||
modelRequest.Model = req.Model
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
|
||||
relayMode := relayconstant.RelayModeAudioSpeech
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/speech") {
|
||||
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "tts-1")
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/translations") {
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model"))
|
||||
// 先尝试从请求读取
|
||||
if req, err := getModelFromRequest(c); err == nil && req.Model != "" {
|
||||
modelRequest.Model = req.Model
|
||||
}
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1")
|
||||
relayMode = relayconstant.RelayModeAudioTranslation
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") {
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model"))
|
||||
// 先尝试从请求读取
|
||||
if req, err := getModelFromRequest(c); err == nil && req.Model != "" {
|
||||
modelRequest.Model = req.Model
|
||||
}
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1")
|
||||
relayMode = relayconstant.RelayModeAudioTranscription
|
||||
}
|
||||
@@ -266,10 +283,12 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
|
||||
// playground chat completions
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
req, err := getModelFromRequest(c)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的请求, " + err.Error())
|
||||
return nil, false, err
|
||||
}
|
||||
modelRequest.Model = req.Model
|
||||
modelRequest.Group = req.Group
|
||||
common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group)
|
||||
}
|
||||
return &modelRequest, shouldSelectChannel, nil
|
||||
|
||||
@@ -102,7 +102,10 @@ func GlobalAPIRateLimit() func(c *gin.Context) {
|
||||
}
|
||||
|
||||
func CriticalRateLimit() func(c *gin.Context) {
|
||||
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
|
||||
if common.CriticalRateLimitEnable {
|
||||
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
|
||||
}
|
||||
return defNext
|
||||
}
|
||||
|
||||
func DownloadRateLimit() func(c *gin.Context) {
|
||||
|
||||
@@ -103,7 +103,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
|
||||
return channelQuery, nil
|
||||
}
|
||||
|
||||
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
|
||||
func GetChannel(group string, model string, retry int) (*Channel, error) {
|
||||
var abilities []Ability
|
||||
|
||||
var err error = nil
|
||||
|
||||
@@ -138,9 +138,11 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
enabledIdx = append(enabledIdx, i)
|
||||
}
|
||||
}
|
||||
// If no specific status list or none enabled, fall back to first key
|
||||
// If no specific status list or none enabled, return an explicit error so caller can
|
||||
// properly handle a channel with no available keys (e.g. mark channel disabled).
|
||||
// Returning the first key here caused requests to keep using an already-disabled key.
|
||||
if len(enabledIdx) == 0 {
|
||||
return keys[0], 0, nil
|
||||
return "", 0, types.NewError(errors.New("no enabled keys"), types.ErrorCodeChannelNoAvailableKey)
|
||||
}
|
||||
|
||||
switch channel.ChannelInfo.MultiKeyMode {
|
||||
@@ -270,13 +272,17 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func GetChannelsByTag(tag string, idSort bool) ([]*Channel, error) {
|
||||
func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) {
|
||||
var channels []*Channel
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
err := DB.Where("tag = ?", tag).Order(order).Find(&channels).Error
|
||||
query := DB.Where("tag = ?", tag).Order(order)
|
||||
if !selectAll {
|
||||
query = query.Omit("key")
|
||||
}
|
||||
err := query.Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
@@ -688,7 +694,7 @@ func DisableChannelByTag(tag string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error {
|
||||
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error {
|
||||
updateData := Channel{}
|
||||
shouldReCreateAbilities := false
|
||||
updatedTag := tag
|
||||
@@ -714,13 +720,19 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
|
||||
if weight != nil {
|
||||
updateData.Weight = weight
|
||||
}
|
||||
if paramOverride != nil {
|
||||
updateData.ParamOverride = paramOverride
|
||||
}
|
||||
if headerOverride != nil {
|
||||
updateData.HeaderOverride = headerOverride
|
||||
}
|
||||
|
||||
err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shouldReCreateAbilities {
|
||||
channels, err := GetChannelsByTag(updatedTag, false)
|
||||
channels, err := GetChannelsByTag(updatedTag, false, false)
|
||||
if err == nil {
|
||||
for _, channel := range channels {
|
||||
err = channel.UpdateAbilities(nil)
|
||||
|
||||
@@ -11,10 +11,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var group2model2channels map[string]map[string][]int // enabled channel
|
||||
@@ -96,43 +93,10 @@ func SyncChannelCache(frequency int) {
|
||||
}
|
||||
}
|
||||
|
||||
func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) {
|
||||
var channel *Channel
|
||||
var err error
|
||||
selectGroup := group
|
||||
if group == "auto" {
|
||||
if len(setting.AutoGroups) == 0 {
|
||||
return nil, selectGroup, errors.New("auto groups is not enabled")
|
||||
}
|
||||
for _, autoGroup := range setting.AutoGroups {
|
||||
if common.DebugEnabled {
|
||||
println("autoGroup:", autoGroup)
|
||||
}
|
||||
channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry)
|
||||
if channel == nil {
|
||||
continue
|
||||
} else {
|
||||
c.Set("auto_group", autoGroup)
|
||||
selectGroup = autoGroup
|
||||
if common.DebugEnabled {
|
||||
println("selectGroup:", selectGroup)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel, err = getRandomSatisfiedChannel(group, model, retry)
|
||||
if err != nil {
|
||||
return nil, group, err
|
||||
}
|
||||
}
|
||||
return channel, selectGroup, nil
|
||||
}
|
||||
|
||||
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
|
||||
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
|
||||
// if memory cache is disabled, get channel directly from database
|
||||
if !common.MemoryCacheEnabled {
|
||||
return GetRandomSatisfiedChannel(group, model, retry)
|
||||
return GetChannel(group, model, retry)
|
||||
}
|
||||
|
||||
channelSyncLock.RLock()
|
||||
@@ -178,10 +142,12 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
|
||||
targetPriority := int64(sortedUniquePriorities[retry])
|
||||
|
||||
// get the priority for the given retry number
|
||||
var sumWeight = 0
|
||||
var targetChannels []*Channel
|
||||
for _, channelId := range channels {
|
||||
if channel, ok := channelsIDM[channelId]; ok {
|
||||
if channel.GetPriority() == targetPriority {
|
||||
sumWeight += channel.GetWeight()
|
||||
targetChannels = append(targetChannels, channel)
|
||||
}
|
||||
} else {
|
||||
@@ -189,19 +155,33 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
|
||||
}
|
||||
}
|
||||
|
||||
// 平滑系数
|
||||
smoothingFactor := 10
|
||||
// Calculate the total weight of all channels up to endIdx
|
||||
totalWeight := 0
|
||||
for _, channel := range targetChannels {
|
||||
totalWeight += channel.GetWeight() + smoothingFactor
|
||||
if len(targetChannels) == 0 {
|
||||
return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority))
|
||||
}
|
||||
|
||||
// smoothing factor and adjustment
|
||||
smoothingFactor := 1
|
||||
smoothingAdjustment := 0
|
||||
|
||||
if sumWeight == 0 {
|
||||
// when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100
|
||||
// each channel's effective weight = 100
|
||||
sumWeight = len(targetChannels) * 100
|
||||
smoothingAdjustment = 100
|
||||
} else if sumWeight/len(targetChannels) < 10 {
|
||||
// when the average weight is less than 10, set smoothing factor to 100
|
||||
smoothingFactor = 100
|
||||
}
|
||||
|
||||
// Calculate the total weight of all channels up to endIdx
|
||||
totalWeight := sumWeight * smoothingFactor
|
||||
|
||||
// Generate a random value in the range [0, totalWeight)
|
||||
randomWeight := rand.Intn(totalWeight)
|
||||
|
||||
// Find a channel based on its weight
|
||||
for _, channel := range targetChannels {
|
||||
randomWeight -= channel.GetWeight() + smoothingFactor
|
||||
randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment
|
||||
if randomWeight < 0 {
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ func InitOptionMap() {
|
||||
common.OptionMap["StripePriceId"] = setting.StripePriceId
|
||||
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
|
||||
common.OptionMap["CreemApiKey"] = setting.CreemApiKey
|
||||
common.OptionMap["CreemProducts"] = setting.CreemProducts
|
||||
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
|
||||
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
|
||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||
@@ -342,6 +346,14 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.StripeMinTopUp, _ = strconv.Atoi(value)
|
||||
case "StripePromotionCodesEnabled":
|
||||
setting.StripePromotionCodesEnabled = value == "true"
|
||||
case "CreemApiKey":
|
||||
setting.CreemApiKey = value
|
||||
case "CreemProducts":
|
||||
setting.CreemProducts = value
|
||||
case "CreemTestMode":
|
||||
setting.CreemTestMode = value == "true"
|
||||
case "CreemWebhookSecret":
|
||||
setting.CreemWebhookSecret = value
|
||||
case "TopupGroupRatio":
|
||||
err = common.UpdateTopupGroupRatioByJSONString(value)
|
||||
case "GitHubClientId":
|
||||
|
||||
@@ -57,8 +57,9 @@ type Task struct {
|
||||
FinishTime int64 `json:"finish_time" gorm:"index"`
|
||||
Progress string `json:"progress" gorm:"type:varchar(20);index"`
|
||||
Properties Properties `json:"properties" gorm:"type:json"`
|
||||
|
||||
Data json.RawMessage `json:"data" gorm:"type:json"`
|
||||
// 禁止返回给用户,内部可能包含key等隐私信息
|
||||
PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"`
|
||||
Data json.RawMessage `json:"data" gorm:"type:json"`
|
||||
}
|
||||
|
||||
func (t *Task) SetData(data any) {
|
||||
@@ -72,18 +73,46 @@ func (t *Task) GetData(v any) error {
|
||||
}
|
||||
|
||||
type Properties struct {
|
||||
Input string `json:"input"`
|
||||
Input string `json:"input"`
|
||||
UpstreamModelName string `json:"upstream_model_name,omitempty"`
|
||||
OriginModelName string `json:"origin_model_name,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Properties) Scan(val interface{}) error {
|
||||
bytesValue, _ := val.([]byte)
|
||||
if len(bytesValue) == 0 {
|
||||
*m = Properties{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytesValue, m)
|
||||
}
|
||||
|
||||
func (m Properties) Value() (driver.Value, error) {
|
||||
if m == (Properties{}) {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
type TaskPrivateData struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
func (p *TaskPrivateData) Scan(val interface{}) error {
|
||||
bytesValue, _ := val.([]byte)
|
||||
if len(bytesValue) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytesValue, p)
|
||||
}
|
||||
|
||||
func (p TaskPrivateData) Value() (driver.Value, error) {
|
||||
if (p == TaskPrivateData{}) {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
|
||||
type SyncTaskQueryParams struct {
|
||||
Platform constant.TaskPlatform
|
||||
@@ -98,14 +127,30 @@ type SyncTaskQueryParams struct {
|
||||
}
|
||||
|
||||
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
|
||||
properties := Properties{}
|
||||
privateData := TaskPrivateData{}
|
||||
if relayInfo != nil && relayInfo.ChannelMeta != nil {
|
||||
if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini {
|
||||
privateData.Key = relayInfo.ChannelMeta.ApiKey
|
||||
}
|
||||
if relayInfo.UpstreamModelName != "" {
|
||||
properties.UpstreamModelName = relayInfo.UpstreamModelName
|
||||
}
|
||||
if relayInfo.OriginModelName != "" {
|
||||
properties.OriginModelName = relayInfo.OriginModelName
|
||||
}
|
||||
}
|
||||
|
||||
t := &Task{
|
||||
UserId: relayInfo.UserId,
|
||||
Group: relayInfo.UsingGroup,
|
||||
SubmitTime: time.Now().Unix(),
|
||||
Status: TaskStatusNotStart,
|
||||
Progress: "0%",
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
Platform: platform,
|
||||
UserId: relayInfo.UserId,
|
||||
Group: relayInfo.UsingGroup,
|
||||
SubmitTime: time.Now().Unix(),
|
||||
Status: TaskStatusNotStart,
|
||||
Progress: "0%",
|
||||
ChannelId: relayInfo.ChannelId,
|
||||
Platform: platform,
|
||||
Properties: properties,
|
||||
PrivateData: privateData,
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -384,3 +429,14 @@ func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
|
||||
_ = query.Count(&total).Error
|
||||
return total
|
||||
}
|
||||
func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo {
|
||||
openAIVideo := dto.NewOpenAIVideo()
|
||||
openAIVideo.ID = t.TaskID
|
||||
openAIVideo.Status = t.Status.ToVideoStatus()
|
||||
openAIVideo.Model = t.Properties.OriginModelName
|
||||
openAIVideo.SetProgressStr(t.Progress)
|
||||
openAIVideo.CreatedAt = t.CreatedAt
|
||||
openAIVideo.CompletedAt = t.UpdatedAt
|
||||
openAIVideo.SetMetadata("url", t.FailReason)
|
||||
return openAIVideo
|
||||
}
|
||||
|
||||
@@ -305,3 +305,72 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
|
||||
return nil
|
||||
}
|
||||
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
|
||||
if referenceId == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
|
||||
var quota int64
|
||||
topUp := &TopUp{}
|
||||
|
||||
refCol := "`trade_no`"
|
||||
if common.UsingPostgreSQL {
|
||||
refCol = `"trade_no"`
|
||||
}
|
||||
|
||||
err = DB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
|
||||
if err != nil {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
topUp.Status = common.TopUpStatusSuccess
|
||||
err = tx.Save(topUp).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Creem 直接使用 Amount 作为充值额度(整数)
|
||||
quota = topUp.Amount
|
||||
|
||||
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
|
||||
updateFields := map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
}
|
||||
|
||||
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
|
||||
if customerEmail != "" {
|
||||
// 先检查用户当前邮箱是否为空
|
||||
var user User
|
||||
err = tx.Where("id = ?", topUp.UserId).First(&user).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果用户邮箱为空,则更新为支付时使用的邮箱
|
||||
if user.Email == "" {
|
||||
updateFields["email"] = customerEmail
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.New("充值失败," + err.Error())
|
||||
}
|
||||
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type User struct {
|
||||
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
|
||||
Email string `json:"email" gorm:"index" validate:"max=50"`
|
||||
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
|
||||
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
@@ -539,6 +540,14 @@ func (user *User) FillUserByGitHubId() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) FillUserByDiscordId() error {
|
||||
if user.DiscordId == "" {
|
||||
return errors.New("discord id 为空!")
|
||||
}
|
||||
DB.Where(User{DiscordId: user.DiscordId}).First(user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (user *User) FillUserByOidcId() error {
|
||||
if user.OidcId == "" {
|
||||
return errors.New("oidc id 为空!")
|
||||
@@ -578,6 +587,10 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
|
||||
return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
|
||||
}
|
||||
|
||||
func IsDiscordIdAlreadyTaken(discordId string) bool {
|
||||
return DB.Unscoped().Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1
|
||||
}
|
||||
|
||||
func IsOidcIdAlreadyTaken(oidcId string) bool {
|
||||
return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1
|
||||
}
|
||||
|
||||
@@ -47,7 +47,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
case constant.RelayModeImagesGenerations:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
|
||||
case constant.RelayModeImagesEdits:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
if isWanModel(info.OriginModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
}
|
||||
case constant.RelayModeCompletions:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
|
||||
default:
|
||||
@@ -71,6 +75,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
req.Set("X-DashScope-Async", "enable")
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
if isWanModel(info.OriginModelName) {
|
||||
req.Set("X-DashScope-Async", "enable")
|
||||
}
|
||||
req.Set("Content-Type", "application/json")
|
||||
}
|
||||
return nil
|
||||
@@ -82,15 +89,15 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
// docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216
|
||||
// fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True.
|
||||
if strings.Contains(request.Model, "thinking") {
|
||||
request.EnableThinking = true
|
||||
request.Stream = true
|
||||
info.IsStream = true
|
||||
}
|
||||
// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
|
||||
if !info.IsStream {
|
||||
request.EnableThinking = false
|
||||
}
|
||||
//if strings.Contains(request.Model, "thinking") {
|
||||
// request.EnableThinking = true
|
||||
// request.Stream = true
|
||||
// info.IsStream = true
|
||||
//}
|
||||
//// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
|
||||
//if !info.IsStream {
|
||||
// request.EnableThinking = false
|
||||
//}
|
||||
|
||||
switch info.RelayMode {
|
||||
default:
|
||||
@@ -107,6 +114,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
return aliRequest, nil
|
||||
} else if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
if isWanModel(info.OriginModelName) {
|
||||
return oaiFormEdit2WanxImageEdit(c, info, request)
|
||||
}
|
||||
// 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") {
|
||||
@@ -161,7 +171,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
case constant.RelayModeImagesGenerations:
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
case constant.RelayModeImagesEdits:
|
||||
err, usage = aliImageEditHandler(c, resp, info)
|
||||
if isWanModel(info.OriginModelName) {
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
} else {
|
||||
err, usage = aliImageEditHandler(c, resp, info)
|
||||
}
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
|
||||
@@ -112,6 +112,19 @@ type AliImageInput struct {
|
||||
Messages []AliMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type WanImageInput struct {
|
||||
Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点
|
||||
Images []string `json:"images"` // 必需:图像URL数组,长度不超过2,支持HTTP/HTTPS URL或Base64编码
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容
|
||||
}
|
||||
|
||||
type WanImageParameters struct {
|
||||
N int `json:"n,omitempty"` // 生成图片数量,取值范围1-4,默认4
|
||||
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识,默认false
|
||||
Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647]
|
||||
Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0,默认0.5(部分模型支持)
|
||||
}
|
||||
|
||||
type AliRerankParameters struct {
|
||||
TopN *int `json:"top_n,omitempty"`
|
||||
ReturnDocuments *bool `json:"return_documents,omitempty"`
|
||||
|
||||
@@ -58,11 +58,7 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
|
||||
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
@@ -98,9 +94,9 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
|
||||
if len(imageFiles) > 1 {
|
||||
return nil, errors.New("only one image is supported for qwen edit")
|
||||
}
|
||||
//if len(imageFiles) > 1 {
|
||||
// return nil, errors.New("only one image is supported for qwen edit")
|
||||
//}
|
||||
|
||||
// 获取base64编码的图片
|
||||
var imageBase64s []string
|
||||
@@ -127,7 +123,18 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
|
||||
imageBase64s = append(imageBase64s, dataURL)
|
||||
image.Close()
|
||||
}
|
||||
return imageBase64s, nil
|
||||
}
|
||||
|
||||
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
|
||||
imageBase64s, err := getImageBase64sFromForm(c, "image")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
|
||||
}
|
||||
//dto.MediaContent{}
|
||||
mediaContents := make([]AliMediaContent, len(imageBase64s))
|
||||
for i, b64 := range imageBase64s {
|
||||
|
||||
39
relay/channel/ali/image_wan.go
Normal file
39
relay/channel/ali/image_wan.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
|
||||
var err error
|
||||
var imageRequest AliImageRequest
|
||||
imageRequest.Model = request.Model
|
||||
imageRequest.ResponseFormat = request.ResponseFormat
|
||||
wanInput := WanImageInput{
|
||||
Prompt: request.Prompt,
|
||||
}
|
||||
|
||||
if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
|
||||
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
|
||||
}
|
||||
wanParams := WanImageParameters{
|
||||
N: int(request.N),
|
||||
}
|
||||
imageRequest.Input = wanInput
|
||||
imageRequest.Parameters = wanParams
|
||||
return &imageRequest, nil
|
||||
}
|
||||
|
||||
func isWanModel(modelName string) bool {
|
||||
return strings.Contains(modelName, "wan")
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
|
||||
@@ -29,180 +16,3 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
|
||||
}
|
||||
return &request
|
||||
}
|
||||
|
||||
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
|
||||
return &AliEmbeddingRequest{
|
||||
Model: request.Model,
|
||||
Input: struct {
|
||||
Texts []string `json:"texts"`
|
||||
}{
|
||||
Texts: request.ParseInput(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
|
||||
var fullTextResponse dto.FlexibleEmbeddingResponse
|
||||
err := json.NewDecoder(resp.Body).Decode(&fullTextResponse)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
model := c.GetString("model")
|
||||
if model == "" {
|
||||
model = "text-embedding-v4"
|
||||
}
|
||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
c.Writer.Write(jsonResponse)
|
||||
return nil, &fullTextResponse.Usage
|
||||
}
|
||||
|
||||
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
|
||||
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
|
||||
Object: "list",
|
||||
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
|
||||
Model: model,
|
||||
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
|
||||
}
|
||||
|
||||
for _, item := range response.Output.Embeddings {
|
||||
openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{
|
||||
Object: `embedding`,
|
||||
Index: item.TextIndex,
|
||||
Embedding: item.Embedding,
|
||||
})
|
||||
}
|
||||
return &openAIEmbeddingResponse
|
||||
}
|
||||
|
||||
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
|
||||
choice := dto.OpenAITextResponseChoice{
|
||||
Index: 0,
|
||||
Message: dto.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Output.Text,
|
||||
},
|
||||
FinishReason: response.Output.FinishReason,
|
||||
}
|
||||
fullTextResponse := dto.OpenAITextResponse{
|
||||
Id: response.RequestId,
|
||||
Object: "chat.completion",
|
||||
Created: common.GetTimestamp(),
|
||||
Choices: []dto.OpenAITextResponseChoice{choice},
|
||||
Usage: dto.Usage{
|
||||
PromptTokens: response.Usage.InputTokens,
|
||||
CompletionTokens: response.Usage.OutputTokens,
|
||||
TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
|
||||
},
|
||||
}
|
||||
return &fullTextResponse
|
||||
}
|
||||
|
||||
func streamResponseAli2OpenAI(aliResponse *AliResponse) *dto.ChatCompletionsStreamResponse {
|
||||
var choice dto.ChatCompletionsStreamResponseChoice
|
||||
choice.Delta.SetContentString(aliResponse.Output.Text)
|
||||
if aliResponse.Output.FinishReason != "null" {
|
||||
finishReason := aliResponse.Output.FinishReason
|
||||
choice.FinishReason = &finishReason
|
||||
}
|
||||
response := dto.ChatCompletionsStreamResponse{
|
||||
Id: aliResponse.RequestId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: common.GetTimestamp(),
|
||||
Model: "ernie-bot",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{choice},
|
||||
}
|
||||
return &response
|
||||
}
|
||||
|
||||
func aliStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
|
||||
var usage dto.Usage
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
dataChan := make(chan string)
|
||||
stopChan := make(chan bool)
|
||||
go func() {
|
||||
for scanner.Scan() {
|
||||
data := scanner.Text()
|
||||
if len(data) < 5 { // ignore blank line or wrong format
|
||||
continue
|
||||
}
|
||||
if data[:5] != "data:" {
|
||||
continue
|
||||
}
|
||||
data = data[5:]
|
||||
dataChan <- data
|
||||
}
|
||||
stopChan <- true
|
||||
}()
|
||||
helper.SetEventStreamHeaders(c)
|
||||
lastResponseText := ""
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
select {
|
||||
case data := <-dataChan:
|
||||
var aliResponse AliResponse
|
||||
err := json.Unmarshal([]byte(data), &aliResponse)
|
||||
if err != nil {
|
||||
common.SysLog("error unmarshalling stream response: " + err.Error())
|
||||
return true
|
||||
}
|
||||
if aliResponse.Usage.OutputTokens != 0 {
|
||||
usage.PromptTokens = aliResponse.Usage.InputTokens
|
||||
usage.CompletionTokens = aliResponse.Usage.OutputTokens
|
||||
usage.TotalTokens = aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens
|
||||
}
|
||||
response := streamResponseAli2OpenAI(&aliResponse)
|
||||
response.Choices[0].Delta.SetContentString(strings.TrimPrefix(response.Choices[0].Delta.GetContentString(), lastResponseText))
|
||||
lastResponseText = aliResponse.Output.Text
|
||||
jsonResponse, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling stream response: " + err.Error())
|
||||
return true
|
||||
}
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
|
||||
return true
|
||||
case <-stopChan:
|
||||
c.Render(-1, common.CustomEvent{Data: "data: [DONE]"})
|
||||
return false
|
||||
}
|
||||
})
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
return nil, &usage
|
||||
}
|
||||
|
||||
func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
|
||||
var aliResponse AliResponse
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
err = json.Unmarshal(responseBody, &aliResponse)
|
||||
if err != nil {
|
||||
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
|
||||
}
|
||||
if aliResponse.Code != "" {
|
||||
return types.WithOpenAIError(types.OpenAIError{
|
||||
Message: aliResponse.Message,
|
||||
Type: "ali_error",
|
||||
Param: aliResponse.RequestId,
|
||||
Code: aliResponse.Code,
|
||||
}, resp.StatusCode), nil
|
||||
}
|
||||
fullTextResponse := responseAli2OpenAI(&aliResponse)
|
||||
jsonResponse, err := common.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = c.Writer.Write(jsonResponse)
|
||||
return nil, &fullTextResponse.Usage
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
|
||||
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
@@ -75,6 +76,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
// Nova models - all support three major regions
|
||||
"amazon.nova-micro-v1:0": {
|
||||
"us": true,
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package aws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
)
|
||||
|
||||
type AwsClaudeRequest struct {
|
||||
// AnthropicVersion should be "bedrock-2023-05-31"
|
||||
AnthropicVersion string `json:"anthropic_version"`
|
||||
AnthropicBeta json.RawMessage `json:"anthropic_beta,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []dto.ClaudeMessage `json:"messages"`
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
@@ -22,29 +28,28 @@ type AwsClaudeRequest struct {
|
||||
Thinking *dto.Thinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
|
||||
return &AwsClaudeRequest{
|
||||
AnthropicVersion: "bedrock-2023-05-31",
|
||||
System: req.System,
|
||||
Messages: req.Messages,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopK: req.TopK,
|
||||
StopSequences: req.StopSequences,
|
||||
Tools: req.Tools,
|
||||
ToolChoice: req.ToolChoice,
|
||||
Thinking: req.Thinking,
|
||||
}
|
||||
}
|
||||
|
||||
func formatRequest(requestBody io.Reader) (*AwsClaudeRequest, error) {
|
||||
func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaudeRequest, error) {
|
||||
var awsClaudeRequest AwsClaudeRequest
|
||||
err := common.DecodeJson(requestBody, &awsClaudeRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
awsClaudeRequest.AnthropicVersion = "bedrock-2023-05-31"
|
||||
|
||||
// check header anthropic-beta
|
||||
anthropicBetaValues := requestHeader.Get("anthropic-beta")
|
||||
if len(anthropicBetaValues) > 0 {
|
||||
var tempArray []string
|
||||
tempArray = strings.Split(anthropicBetaValues, ",")
|
||||
if len(tempArray) > 0 {
|
||||
betaJson, err := json.Marshal(tempArray)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
awsClaudeRequest.AnthropicBeta = betaJson
|
||||
}
|
||||
}
|
||||
logger.LogJson(context.Background(), "json", awsClaudeRequest)
|
||||
return &awsClaudeRequest, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel/claude"
|
||||
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"
|
||||
@@ -25,6 +26,19 @@ import (
|
||||
)
|
||||
|
||||
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
|
||||
var (
|
||||
httpClient *http.Client
|
||||
err error
|
||||
)
|
||||
if info.ChannelSetting.Proxy != "" {
|
||||
httpClient, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new proxy http client failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
httpClient = service.GetHttpClient()
|
||||
}
|
||||
|
||||
awsSecret := strings.Split(info.ApiKey, "|")
|
||||
var client *bedrockruntime.Client
|
||||
switch len(awsSecret) {
|
||||
@@ -34,6 +48,7 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.
|
||||
client = bedrockruntime.New(bedrockruntime.Options{
|
||||
Region: region,
|
||||
BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}},
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
case 3:
|
||||
ak := awsSecret[0]
|
||||
@@ -42,6 +57,7 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.
|
||||
client = bedrockruntime.New(bedrockruntime.Options{
|
||||
Region: region,
|
||||
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")),
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
default:
|
||||
return nil, errors.New("invalid aws secret key")
|
||||
@@ -57,7 +73,6 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
|
||||
}
|
||||
a.AwsClient = awsCli
|
||||
|
||||
println(info.UpstreamModelName)
|
||||
// 获取对应的AWS模型ID
|
||||
awsModelId := getAwsModelID(info.UpstreamModelName)
|
||||
|
||||
@@ -67,6 +82,10 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
|
||||
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
|
||||
}
|
||||
|
||||
// init empty request.header
|
||||
requestHeader := http.Header{}
|
||||
a.SetupRequestHeader(c, &requestHeader, info)
|
||||
|
||||
if isNovaModel(awsModelId) {
|
||||
var novaReq *NovaRequest
|
||||
err = common.DecodeJson(requestBody, &novaReq)
|
||||
@@ -88,7 +107,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
|
||||
awsReq.Body = reqBody
|
||||
return nil, nil
|
||||
} else {
|
||||
awsClaudeReq, err := formatRequest(requestBody)
|
||||
awsClaudeReq, err := formatRequest(requestBody, requestHeader)
|
||||
if err != nil {
|
||||
return nil, types.NewError(errors.Wrap(err, "format aws request fail"), types.ErrorCodeBadRequestBody)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
claudeRequest.TopP = 0
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
}
|
||||
}
|
||||
|
||||
if textRequest.ReasoningEffort != "" {
|
||||
@@ -596,6 +598,8 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
if claudeResponse.Delta.Text != nil {
|
||||
@@ -669,7 +673,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) {
|
||||
|
||||
if requestMode == RequestModeCompletion {
|
||||
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
} else {
|
||||
if claudeInfo.Usage.PromptTokens == 0 {
|
||||
//上游出错
|
||||
@@ -678,7 +682,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("claude response usage is not complete, maybe upstream error")
|
||||
}
|
||||
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,6 +744,8 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
|
||||
}
|
||||
var responseData []byte
|
||||
switch info.RelayFormat {
|
||||
|
||||
@@ -74,7 +74,7 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.LogError(c, "error_scanning_stream_response: "+err.Error())
|
||||
}
|
||||
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
if info.ShouldIncludeUsage {
|
||||
response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage)
|
||||
err := helper.ObjectData(c, response)
|
||||
@@ -105,7 +105,7 @@ func cfHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response)
|
||||
for _, choice := range response.Choices {
|
||||
responseText += choice.Message.StringContent()
|
||||
}
|
||||
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
response.Usage = *usage
|
||||
response.Id = helper.GetResponseID(c)
|
||||
jsonResponse, err := json.Marshal(response)
|
||||
|
||||
@@ -165,7 +165,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
}
|
||||
})
|
||||
if usage.PromptTokens == 0 {
|
||||
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
|
||||
helper.Done(c)
|
||||
|
||||
if usage.TotalTokens == 0 {
|
||||
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
|
||||
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
|
||||
@@ -246,7 +246,7 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
})
|
||||
helper.Done(c)
|
||||
if usage.TotalTokens == 0 {
|
||||
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
}
|
||||
usage.CompletionTokens += nodeToken
|
||||
return usage, nil
|
||||
|
||||
@@ -127,7 +127,8 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
|
||||
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
|
||||
// 新增逻辑:处理 -thinking-<budget> 格式
|
||||
if strings.Contains(info.UpstreamModelName, "-thinking-") {
|
||||
parts := strings.Split(info.UpstreamModelName, "-thinking-")
|
||||
@@ -176,7 +177,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
|
||||
geminiRequest, err := CovertGemini2OpenAI(c, *request, info)
|
||||
geminiRequest, err := CovertOpenAI2Gemini(c, *request, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ var ModelList = []string{
|
||||
"gemini-1.5-pro-latest", "gemini-1.5-flash-latest",
|
||||
// preview version
|
||||
"gemini-2.0-flash-lite-preview",
|
||||
"gemini-3-pro-preview",
|
||||
// gemini exp
|
||||
"gemini-exp-1206",
|
||||
// flash exp
|
||||
@@ -31,7 +32,7 @@ var SafetySettingList = []string{
|
||||
"HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"HARM_CATEGORY_CIVIC_INTEGRITY",
|
||||
//"HARM_CATEGORY_CIVIC_INTEGRITY", This item is deprecated!
|
||||
}
|
||||
|
||||
var ChannelName = "google gemini"
|
||||
|
||||
@@ -3,9 +3,9 @@ package gemini
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -77,6 +75,8 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel
|
||||
TotalTokens: info.PromptTokens,
|
||||
}
|
||||
|
||||
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
|
||||
|
||||
if info.IsGeminiBatchEmbedding {
|
||||
var geminiResponse dto.GeminiBatchEmbeddingResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
@@ -97,80 +97,15 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel
|
||||
}
|
||||
|
||||
func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
|
||||
responseText := strings.Builder{}
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
err := common.UnmarshalJsonStr(data, &geminiResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, "error unmarshalling stream response: "+err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
// 统计图片数量
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
imageCount++
|
||||
}
|
||||
if part.Text != "" {
|
||||
responseText.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用量统计
|
||||
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
|
||||
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
info.SendResponseCount++
|
||||
return true
|
||||
})
|
||||
|
||||
if info.SendResponseCount == 0 {
|
||||
return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if imageCount != 0 {
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 258
|
||||
}
|
||||
}
|
||||
|
||||
// 如果usage.CompletionTokens为0,则使用本地统计的completion tokens
|
||||
if usage.CompletionTokens == 0 {
|
||||
str := responseText.String()
|
||||
if len(str) > 0 {
|
||||
usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
} else {
|
||||
// 空补全,不需要使用量
|
||||
usage = &dto.Usage{}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
|
||||
//helper.Done(c)
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ var geminiSupportedMimeTypes = map[string]bool{
|
||||
"video/flv": true,
|
||||
}
|
||||
|
||||
const thoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
|
||||
|
||||
// Gemini 允许的思考预算范围
|
||||
const (
|
||||
pro25MinBudget = 128
|
||||
@@ -181,7 +183,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel
|
||||
}
|
||||
|
||||
// Setting safety to the lowest possible values since Gemini is already powerless enough
|
||||
func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||
|
||||
geminiRequest := dto.GeminiChatRequest{
|
||||
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
|
||||
@@ -193,6 +195,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
},
|
||||
}
|
||||
|
||||
attachThoughtSignature := (info.ChannelType == constant.ChannelTypeGemini ||
|
||||
info.ChannelType == constant.ChannelTypeVertexAi) &&
|
||||
model_setting.GetGeminiSettings().FunctionCallThoughtSignatureEnabled
|
||||
|
||||
if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{
|
||||
"TEXT",
|
||||
@@ -371,6 +377,8 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
content := dto.GeminiChatContent{
|
||||
Role: message.Role,
|
||||
}
|
||||
shouldAttachThoughtSignature := attachThoughtSignature && (message.Role == "assistant" || message.Role == "model")
|
||||
signatureAttached := false
|
||||
// isToolCall := false
|
||||
if message.ToolCalls != nil {
|
||||
// message.Role = "model"
|
||||
@@ -388,6 +396,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
Arguments: args,
|
||||
},
|
||||
}
|
||||
if shouldAttachThoughtSignature && !signatureAttached && hasFunctionCallContent(toolCall.FunctionCall) && len(toolCall.ThoughtSignature) == 0 {
|
||||
toolCall.ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
|
||||
signatureAttached = true
|
||||
}
|
||||
parts = append(parts, toolCall)
|
||||
tool_call_ids[call.ID] = call.Function.Name
|
||||
}
|
||||
@@ -496,6 +508,28 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
return &geminiRequest, nil
|
||||
}
|
||||
|
||||
func hasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||
if call == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(call.FunctionName) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
switch v := call.Arguments.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return strings.TrimSpace(v) != ""
|
||||
case map[string]interface{}:
|
||||
return len(v) > 0
|
||||
case []interface{}:
|
||||
return len(v) > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get a list of supported MIME types for error messages
|
||||
func getSupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(geminiSupportedMimeTypes))
|
||||
@@ -920,14 +954,10 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
// responseText := ""
|
||||
id := helper.GetResponseID(c)
|
||||
createAt := common.GetTimestamp()
|
||||
responseText := strings.Builder{}
|
||||
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
finishReason := constant.FinishReasonStop
|
||||
responseText := strings.Builder{}
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
@@ -937,6 +967,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
return false
|
||||
}
|
||||
|
||||
// 统计图片数量
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
@@ -948,14 +979,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
}
|
||||
|
||||
response, isStop := streamResponseGeminiChat2OpenAI(&geminiResponse)
|
||||
|
||||
response.Id = id
|
||||
response.Created = createAt
|
||||
response.Model = info.UpstreamModelName
|
||||
// 更新使用量统计
|
||||
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
|
||||
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
@@ -966,6 +993,45 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return callback(data, &geminiResponse)
|
||||
})
|
||||
|
||||
if imageCount != 0 {
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 1400
|
||||
}
|
||||
}
|
||||
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
if usage.TotalTokens > 0 {
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
}
|
||||
|
||||
if usage.CompletionTokens <= 0 {
|
||||
str := responseText.String()
|
||||
if len(str) > 0 {
|
||||
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
} else {
|
||||
usage = &dto.Usage{}
|
||||
}
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
id := helper.GetResponseID(c)
|
||||
createAt := common.GetTimestamp()
|
||||
finishReason := constant.FinishReasonStop
|
||||
|
||||
usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
||||
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
|
||||
|
||||
response.Id = id
|
||||
response.Created = createAt
|
||||
response.Model = info.UpstreamModelName
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount))
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
@@ -981,7 +1047,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls
|
||||
}
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
err = handleStream(c, info, emptyResponse)
|
||||
err := handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
@@ -991,14 +1057,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
response.Choices[0].FinishReason = nil
|
||||
}
|
||||
} else {
|
||||
err = handleStream(c, info, emptyResponse)
|
||||
err := handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = handleStream(c, info, response)
|
||||
err := handleStream(c, info, response)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
@@ -1008,40 +1074,15 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
return true
|
||||
})
|
||||
|
||||
if info.SendResponseCount == 0 {
|
||||
// 空补全,报错不计费
|
||||
// empty response, throw an error
|
||||
return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if imageCount != 0 {
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 258
|
||||
}
|
||||
}
|
||||
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
|
||||
if usage.CompletionTokens == 0 {
|
||||
str := responseText.String()
|
||||
if len(str) > 0 {
|
||||
usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
} else {
|
||||
// 空补全,不需要使用量
|
||||
usage = &dto.Usage{}
|
||||
}
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
|
||||
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
|
||||
err := handleFinalStream(c, info, response)
|
||||
if err != nil {
|
||||
common.SysLog("send final response failed: " + err.Error())
|
||||
handleErr := handleFinalStream(c, info, response)
|
||||
if handleErr != nil {
|
||||
common.SysLog("send final response failed: " + handleErr.Error())
|
||||
}
|
||||
//if info.RelayFormat == relaycommon.RelayFormatOpenAI {
|
||||
// helper.Done(c)
|
||||
//}
|
||||
//resp.Body.Close()
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
132
relay/channel/minimax/adaptor.go
Normal file
132
relay/channel/minimax/adaptor.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
if info.RelayMode != constant.RelayModeAudioSpeech {
|
||||
return nil, errors.New("unsupported audio relay mode")
|
||||
}
|
||||
|
||||
voiceID := request.Voice
|
||||
speed := request.Speed
|
||||
outputFormat := request.ResponseFormat
|
||||
|
||||
minimaxRequest := MiniMaxTTSRequest{
|
||||
Model: info.OriginModelName,
|
||||
Text: request.Input,
|
||||
VoiceSetting: VoiceSetting{
|
||||
VoiceID: voiceID,
|
||||
Speed: speed,
|
||||
},
|
||||
AudioSetting: &AudioSetting{
|
||||
Format: outputFormat,
|
||||
},
|
||||
OutputFormat: outputFormat,
|
||||
}
|
||||
|
||||
// 同步扩展字段的厂商自定义metadata
|
||||
if len(request.Metadata) > 0 {
|
||||
if err := json.Unmarshal(request.Metadata, &minimaxRequest); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling metadata to minimax request: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(minimaxRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling minimax request: %w", err)
|
||||
}
|
||||
if outputFormat != "hex" {
|
||||
outputFormat = "url"
|
||||
}
|
||||
|
||||
c.Set("response_format", outputFormat)
|
||||
|
||||
// Debug: log the request structure
|
||||
// fmt.Printf("MiniMax TTS Request: %s\n", string(jsonData))
|
||||
|
||||
return bytes.NewReader(jsonData), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return GetRequestURL(info)
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
return handleTTSResponse(c, resp, info)
|
||||
}
|
||||
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
@@ -8,6 +8,12 @@ var ModelList = []string{
|
||||
"abab6-chat",
|
||||
"abab5.5-chat",
|
||||
"abab5.5s-chat",
|
||||
"speech-2.5-hd-preview",
|
||||
"speech-2.5-turbo-preview",
|
||||
"speech-02-hd",
|
||||
"speech-02-turbo",
|
||||
"speech-01-hd",
|
||||
"speech-01-turbo",
|
||||
}
|
||||
|
||||
var ChannelName = "minimax"
|
||||
|
||||
@@ -3,9 +3,23 @@ package minimax
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
channelconstant "github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
)
|
||||
|
||||
func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", info.ChannelBaseUrl), nil
|
||||
baseUrl := info.ChannelBaseUrl
|
||||
if baseUrl == "" {
|
||||
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeMiniMax]
|
||||
}
|
||||
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||
}
|
||||
}
|
||||
|
||||
194
relay/channel/minimax/tts.go
Normal file
194
relay/channel/minimax/tts.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MiniMaxTTSRequest struct {
|
||||
Model string `json:"model"`
|
||||
Text string `json:"text"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
|
||||
VoiceSetting VoiceSetting `json:"voice_setting"`
|
||||
PronunciationDict *PronunciationDict `json:"pronunciation_dict,omitempty"`
|
||||
AudioSetting *AudioSetting `json:"audio_setting,omitempty"`
|
||||
TimbreWeights []TimbreWeight `json:"timbre_weights,omitempty"`
|
||||
LanguageBoost string `json:"language_boost,omitempty"`
|
||||
VoiceModify *VoiceModify `json:"voice_modify,omitempty"`
|
||||
SubtitleEnable bool `json:"subtitle_enable,omitempty"`
|
||||
OutputFormat string `json:"output_format,omitempty"`
|
||||
AigcWatermark bool `json:"aigc_watermark,omitempty"`
|
||||
}
|
||||
|
||||
type StreamOptions struct {
|
||||
ExcludeAggregatedAudio bool `json:"exclude_aggregated_audio,omitempty"`
|
||||
}
|
||||
|
||||
type VoiceSetting struct {
|
||||
VoiceID string `json:"voice_id"`
|
||||
Speed float64 `json:"speed,omitempty"`
|
||||
Vol float64 `json:"vol,omitempty"`
|
||||
Pitch int `json:"pitch,omitempty"`
|
||||
Emotion string `json:"emotion,omitempty"`
|
||||
TextNormalization bool `json:"text_normalization,omitempty"`
|
||||
LatexRead bool `json:"latex_read,omitempty"`
|
||||
}
|
||||
|
||||
type PronunciationDict struct {
|
||||
Tone []string `json:"tone,omitempty"`
|
||||
}
|
||||
|
||||
type AudioSetting struct {
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Channel int `json:"channel,omitempty"`
|
||||
ForceCbr bool `json:"force_cbr,omitempty"`
|
||||
}
|
||||
|
||||
type TimbreWeight struct {
|
||||
VoiceID string `json:"voice_id"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
type VoiceModify struct {
|
||||
Pitch int `json:"pitch,omitempty"`
|
||||
Intensity int `json:"intensity,omitempty"`
|
||||
Timbre int `json:"timbre,omitempty"`
|
||||
SoundEffects string `json:"sound_effects,omitempty"`
|
||||
}
|
||||
|
||||
type MiniMaxTTSResponse struct {
|
||||
Data MiniMaxTTSData `json:"data"`
|
||||
ExtraInfo MiniMaxExtraInfo `json:"extra_info"`
|
||||
TraceID string `json:"trace_id"`
|
||||
BaseResp MiniMaxBaseResp `json:"base_resp"`
|
||||
}
|
||||
|
||||
type MiniMaxTTSData struct {
|
||||
Audio string `json:"audio"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type MiniMaxExtraInfo struct {
|
||||
UsageCharacters int64 `json:"usage_characters"`
|
||||
}
|
||||
|
||||
type MiniMaxBaseResp struct {
|
||||
StatusCode int64 `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
}
|
||||
|
||||
func getContentTypeByFormat(format string) string {
|
||||
contentTypeMap := map[string]string{
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"flac": "audio/flac",
|
||||
"aac": "audio/aac",
|
||||
"pcm": "audio/pcm",
|
||||
}
|
||||
if ct, ok := contentTypeMap[format]; ok {
|
||||
return ct
|
||||
}
|
||||
return "audio/mpeg" // default to mp3
|
||||
}
|
||||
|
||||
func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("failed to read minimax response: %w", readErr),
|
||||
types.ErrorCodeReadResponseBodyFailed,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse response
|
||||
var minimaxResp MiniMaxTTSResponse
|
||||
if unmarshalErr := json.Unmarshal(body, &minimaxResp); unmarshalErr != nil {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("failed to unmarshal minimax TTS response: %w", unmarshalErr),
|
||||
types.ErrorCodeBadResponseBody,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// Check base_resp status code
|
||||
if minimaxResp.BaseResp.StatusCode != 0 {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("minimax TTS error: %d - %s", minimaxResp.BaseResp.StatusCode, minimaxResp.BaseResp.StatusMsg),
|
||||
types.ErrorCodeBadResponse,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if we have audio data
|
||||
if minimaxResp.Data.Audio == "" {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("no audio data in minimax TTS response"),
|
||||
types.ErrorCodeBadResponse,
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(minimaxResp.Data.Audio, "http") {
|
||||
c.Redirect(http.StatusFound, minimaxResp.Data.Audio)
|
||||
} else {
|
||||
// Handle hex-encoded audio data
|
||||
audioData, decodeErr := hex.DecodeString(minimaxResp.Data.Audio)
|
||||
if decodeErr != nil {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("failed to decode hex audio data: %w", decodeErr),
|
||||
types.ErrorCodeBadResponse,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// Determine content type - default to mp3
|
||||
contentType := "audio/mpeg"
|
||||
|
||||
c.Data(http.StatusOK, contentType, audioData)
|
||||
}
|
||||
|
||||
usage = &dto.Usage{
|
||||
PromptTokens: info.PromptTokens,
|
||||
CompletionTokens: 0,
|
||||
TotalTokens: int(minimaxResp.ExtraInfo.UsageCharacters),
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func handleChatCompletionResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
errors.New("failed to read minimax response"),
|
||||
types.ErrorCodeReadResponseBodyFailed,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Set response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -121,7 +121,14 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||
// Unmarshal the JSON string to get the actual content without quotes
|
||||
var thinkingContent string
|
||||
if err := json.Unmarshal(chunk.Message.Thinking, &thinkingContent); err == nil {
|
||||
delta.Choices[0].Delta.SetReasoningContent(thinkingContent)
|
||||
} else {
|
||||
// Fallback to raw string if it's not a JSON string
|
||||
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// tool calls
|
||||
@@ -209,7 +216,14 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
// Unmarshal the JSON string to get the actual content without quotes
|
||||
var thinkingContent string
|
||||
if err := json.Unmarshal(ck.Message.Thinking, &thinkingContent); err == nil {
|
||||
reasoningBuilder.WriteString(thinkingContent)
|
||||
} else {
|
||||
// Fallback to raw string if it's not a JSON string
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" {
|
||||
@@ -229,7 +243,14 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
if len(single.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(single.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
// Unmarshal the JSON string to get the actual content without quotes
|
||||
var thinkingContent string
|
||||
if err := json.Unmarshal(single.Message.Thinking, &thinkingContent); err == nil {
|
||||
reasoningBuilder.WriteString(thinkingContent)
|
||||
} else {
|
||||
// Fallback to raw string if it's not a JSON string
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
|
||||
@@ -15,16 +15,19 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ai360"
|
||||
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
|
||||
"github.com/QuantumNous/new-api/relay/channel/minimax"
|
||||
|
||||
//"github.com/QuantumNous/new-api/relay/channel/minimax"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openrouter"
|
||||
"github.com/QuantumNous/new-api/relay/channel/xinference"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/common_handler"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -39,7 +42,7 @@ type Adaptor struct {
|
||||
// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc...
|
||||
// minimal effort only available in gpt-5
|
||||
func parseReasoningEffortFromModelSuffix(model string) (string, string) {
|
||||
effortSuffixes := []string{"-high", "-minimal", "-low", "-medium"}
|
||||
effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none"}
|
||||
for _, suffix := range effortSuffixes {
|
||||
if strings.HasSuffix(model, suffix) {
|
||||
effort := strings.TrimPrefix(suffix, "-")
|
||||
@@ -161,8 +164,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
requestURL = fmt.Sprintf("/openai/realtime?deployment=%s&api-version=%s", model_, apiVersion)
|
||||
}
|
||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
|
||||
case constant.ChannelTypeMiniMax:
|
||||
return minimax.GetRequestURL(info)
|
||||
//case constant.ChannelTypeMiniMax:
|
||||
// return minimax.GetRequestURL(info)
|
||||
case constant.ChannelTypeCustom:
|
||||
url := info.ChannelBaseUrl
|
||||
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
|
||||
@@ -222,7 +225,8 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
request.Usage = json.RawMessage(`{"include":true}`)
|
||||
}
|
||||
// 适配 OpenRouter 的 thinking 后缀
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-thinking") {
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
|
||||
strings.HasSuffix(info.UpstreamModelName, "-thinking") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
|
||||
request.Model = info.UpstreamModelName
|
||||
if len(request.Reasoning) == 0 {
|
||||
@@ -352,27 +356,43 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
|
||||
writer.WriteField("model", request.Model)
|
||||
|
||||
// 获取所有表单字段
|
||||
formData := c.Request.PostForm
|
||||
formData, err2 := common.ParseMultipartFormReusable(c)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("error parsing multipart form: %w", err2)
|
||||
}
|
||||
|
||||
// 打印类似 curl 命令格式的信息
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model))
|
||||
|
||||
// 遍历表单字段并打印输出
|
||||
for key, values := range formData {
|
||||
for key, values := range formData.Value {
|
||||
if key == "model" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
writer.WriteField(key, value)
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文件字段
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
// 从 formData 中获取文件
|
||||
fileHeaders := formData.File["file"]
|
||||
if len(fileHeaders) == 0 {
|
||||
return nil, errors.New("file is required")
|
||||
}
|
||||
|
||||
// 使用 formData 中的第一个文件
|
||||
fileHeader := fileHeaders[0]
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
|
||||
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type")))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening audio file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
part, err := writer.CreateFormFile("file", header.Filename)
|
||||
part, err := writer.CreateFormFile("file", fileHeader.Filename)
|
||||
if err != nil {
|
||||
return nil, errors.New("create form file failed")
|
||||
}
|
||||
@@ -383,6 +403,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
// 关闭 multipart 编写器以设置分界线
|
||||
writer.Close()
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType()))
|
||||
return &requestBody, nil
|
||||
}
|
||||
}
|
||||
@@ -599,8 +620,8 @@ func (a *Adaptor) GetModelList() []string {
|
||||
return ai360.ModelList
|
||||
case constant.ChannelTypeLingYiWanWu:
|
||||
return lingyiwanwu.ModelList
|
||||
case constant.ChannelTypeMiniMax:
|
||||
return minimax.ModelList
|
||||
//case constant.ChannelTypeMiniMax:
|
||||
// return minimax.ModelList
|
||||
case constant.ChannelTypeXinference:
|
||||
return xinference.ModelList
|
||||
case constant.ChannelTypeOpenRouter:
|
||||
@@ -616,8 +637,8 @@ func (a *Adaptor) GetChannelName() string {
|
||||
return ai360.ChannelName
|
||||
case constant.ChannelTypeLingYiWanWu:
|
||||
return lingyiwanwu.ChannelName
|
||||
case constant.ChannelTypeMiniMax:
|
||||
return minimax.ChannelName
|
||||
//case constant.ChannelTypeMiniMax:
|
||||
// return minimax.ChannelName
|
||||
case constant.ChannelTypeXinference:
|
||||
return xinference.ChannelName
|
||||
case constant.ChannelTypeOpenRouter:
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -26,7 +21,6 @@ import (
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
|
||||
@@ -128,6 +122,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
var usage = &dto.Usage{}
|
||||
var streamItems []string // store stream items
|
||||
var lastStreamData string
|
||||
var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
|
||||
|
||||
// 检查是否为音频模型
|
||||
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
if lastStreamData != "" {
|
||||
@@ -137,12 +135,35 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
}
|
||||
}
|
||||
if len(data) > 0 {
|
||||
// 对音频模型,保存倒数第二个stream data
|
||||
if isAudioModel && lastStreamData != "" {
|
||||
secondLastStreamData = lastStreamData
|
||||
}
|
||||
|
||||
lastStreamData = data
|
||||
streamItems = append(streamItems, data)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 对音频模型,从倒数第二个stream data中提取usage信息
|
||||
if isAudioModel && secondLastStreamData != "" {
|
||||
var streamResp struct {
|
||||
Usage *dto.Usage `json:"usage"`
|
||||
}
|
||||
err := json.Unmarshal([]byte(secondLastStreamData), &streamResp)
|
||||
if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
|
||||
usage = streamResp.Usage
|
||||
containStreamUsage = true
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
|
||||
usage.InputTokens, usage.OutputTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后的响应
|
||||
shouldSendLastResp := true
|
||||
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
|
||||
@@ -162,7 +183,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
}
|
||||
|
||||
if !containStreamUsage {
|
||||
usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
usage.CompletionTokens += toolCount * 7
|
||||
}
|
||||
|
||||
@@ -273,6 +294,39 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
return &simpleResponse.Usage, nil
|
||||
}
|
||||
|
||||
func streamTTSResponse(c *gin.Context, resp *http.Response) {
|
||||
c.Writer.WriteHeaderNow()
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
logger.LogWarn(c, "streaming not supported")
|
||||
_, err := io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
logger.LogWarn(c, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buffer)
|
||||
//logger.LogInfo(c, fmt.Sprintf("streamTTSResponse read %d bytes", n))
|
||||
if n > 0 {
|
||||
if _, writeErr := c.Writer.Write(buffer[:n]); writeErr != nil {
|
||||
logger.LogError(c, writeErr.Error())
|
||||
break
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -288,10 +342,16 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
c.Writer.WriteHeaderNow()
|
||||
_, err := io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c, err.Error())
|
||||
|
||||
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
|
||||
}
|
||||
@@ -322,59 +382,13 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
}
|
||||
}
|
||||
|
||||
audioTokens, err := countAudioTokens(c)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeCountTokenFailed), nil
|
||||
}
|
||||
usage := &dto.Usage{}
|
||||
usage.PromptTokens = audioTokens
|
||||
usage.PromptTokens = info.PromptTokens
|
||||
usage.CompletionTokens = 0
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
return nil, usage
|
||||
}
|
||||
|
||||
func countAudioTokens(c *gin.Context) (int, error) {
|
||||
body, err := common.GetRequestBody(c)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
var reqBody struct {
|
||||
File *multipart.FileHeader `form:"file" binding:"required"`
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err = c.ShouldBind(&reqBody); err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名
|
||||
reqFp, err := reqBody.File.Open()
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
defer reqFp.Close()
|
||||
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
defer os.Remove(tmpFp.Name())
|
||||
|
||||
_, err = io.Copy(tmpFp, reqFp)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
if err = tmpFp.Close(); err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name(), ext)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return int(math.Round(math.Ceil(duration) / 60.0 * 1000)), nil // 1 minute 相当于 1k tokens
|
||||
}
|
||||
|
||||
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
|
||||
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
|
||||
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
|
||||
|
||||
@@ -81,7 +81,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
if info.IsStream {
|
||||
var responseText string
|
||||
err, responseText = palmStreamHandler(c, resp)
|
||||
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
|
||||
} else {
|
||||
usage, err = palmHandler(c, info, resp)
|
||||
}
|
||||
|
||||
530
relay/channel/replicate/adaptor.go
Normal file
530
relay/channel/replicate/adaptor.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package replicate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info == nil {
|
||||
return "", errors.New("replicate adaptor: relay info is nil")
|
||||
}
|
||||
if info.ChannelBaseUrl == "" {
|
||||
info.ChannelBaseUrl = constant.ChannelBaseURLs[constant.ChannelTypeReplicate]
|
||||
}
|
||||
requestPath := info.RequestURLPath
|
||||
if requestPath == "" {
|
||||
return info.ChannelBaseUrl, nil
|
||||
}
|
||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestPath, info.ChannelType), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
if info == nil {
|
||||
return errors.New("replicate adaptor: relay info is nil")
|
||||
}
|
||||
if info.ApiKey == "" {
|
||||
return errors.New("replicate adaptor: api key is required")
|
||||
}
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
req.Set("Prefer", "wait")
|
||||
if req.Get("Content-Type") == "" {
|
||||
req.Set("Content-Type", "application/json")
|
||||
}
|
||||
if req.Get("Accept") == "" {
|
||||
req.Set("Accept", "application/json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
if info == nil {
|
||||
return nil, errors.New("replicate adaptor: relay info is nil")
|
||||
}
|
||||
if strings.TrimSpace(request.Prompt) == "" {
|
||||
if v := c.PostForm("prompt"); strings.TrimSpace(v) != "" {
|
||||
request.Prompt = v
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(request.Prompt) == "" {
|
||||
return nil, errors.New("replicate adaptor: prompt is required")
|
||||
}
|
||||
|
||||
modelName := strings.TrimSpace(info.UpstreamModelName)
|
||||
if modelName == "" {
|
||||
modelName = strings.TrimSpace(request.Model)
|
||||
}
|
||||
if modelName == "" {
|
||||
modelName = ModelFlux11Pro
|
||||
}
|
||||
info.UpstreamModelName = modelName
|
||||
|
||||
info.RequestURLPath = fmt.Sprintf("/v1/models/%s/predictions", modelName)
|
||||
|
||||
inputPayload := make(map[string]any)
|
||||
inputPayload["prompt"] = request.Prompt
|
||||
|
||||
if size := strings.TrimSpace(request.Size); size != "" {
|
||||
if aspect, width, height, ok := mapOpenAISizeToFlux(size); ok {
|
||||
if aspect != "" {
|
||||
if aspect == "custom" {
|
||||
inputPayload["aspect_ratio"] = "custom"
|
||||
if width > 0 {
|
||||
inputPayload["width"] = width
|
||||
}
|
||||
if height > 0 {
|
||||
inputPayload["height"] = height
|
||||
}
|
||||
} else {
|
||||
inputPayload["aspect_ratio"] = aspect
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(request.OutputFormat) > 0 {
|
||||
var outputFormat string
|
||||
if err := json.Unmarshal(request.OutputFormat, &outputFormat); err == nil && strings.TrimSpace(outputFormat) != "" {
|
||||
inputPayload["output_format"] = outputFormat
|
||||
}
|
||||
}
|
||||
|
||||
if request.N > 0 {
|
||||
inputPayload["num_outputs"] = int(request.N)
|
||||
}
|
||||
|
||||
if strings.EqualFold(request.Quality, "hd") || strings.EqualFold(request.Quality, "high") {
|
||||
inputPayload["prompt_upsampling"] = true
|
||||
}
|
||||
|
||||
if info.RelayMode == relayconstant.RelayModeImagesEdits {
|
||||
imageURL, err := uploadFileFromForm(c, info, "image", "image[]", "image_prompt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if imageURL == "" {
|
||||
return nil, errors.New("replicate adaptor: image file is required for edits")
|
||||
}
|
||||
inputPayload["image_prompt"] = imageURL
|
||||
}
|
||||
|
||||
if len(request.ExtraFields) > 0 {
|
||||
var extra map[string]any
|
||||
if err := common.Unmarshal(request.ExtraFields, &extra); err != nil {
|
||||
return nil, fmt.Errorf("replicate adaptor: failed to decode extra_fields: %w", err)
|
||||
}
|
||||
for key, val := range extra {
|
||||
inputPayload[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
for key, raw := range request.Extra {
|
||||
if strings.EqualFold(key, "input") {
|
||||
var extraInput map[string]any
|
||||
if err := common.Unmarshal(raw, &extraInput); err != nil {
|
||||
return nil, fmt.Errorf("replicate adaptor: failed to decode extra input: %w", err)
|
||||
}
|
||||
for k, v := range extraInput {
|
||||
inputPayload[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
var val any
|
||||
if err := common.Unmarshal(raw, &val); err != nil {
|
||||
return nil, fmt.Errorf("replicate adaptor: failed to decode extra field %s: %w", key, err)
|
||||
}
|
||||
inputPayload[key] = val
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"input": inputPayload,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (any, *types.NewAPIError) {
|
||||
if resp == nil {
|
||||
return nil, types.NewError(errors.New("replicate adaptor: empty response"), types.ErrorCodeBadResponse)
|
||||
}
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var prediction PredictionResponse
|
||||
if err := common.Unmarshal(responseBody, &prediction); err != nil {
|
||||
return nil, types.NewError(fmt.Errorf("replicate adaptor: failed to decode response: %w", err), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
if prediction.Error != nil {
|
||||
errMsg := prediction.Error.Message
|
||||
if errMsg == "" {
|
||||
errMsg = prediction.Error.Detail
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = prediction.Error.Code
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "replicate adaptor: prediction error"
|
||||
}
|
||||
return nil, types.NewError(errors.New(errMsg), types.ErrorCodeBadResponse)
|
||||
}
|
||||
|
||||
if prediction.Status != "" && !strings.EqualFold(prediction.Status, "succeeded") {
|
||||
return nil, types.NewError(fmt.Errorf("replicate adaptor: prediction status %q", prediction.Status), types.ErrorCodeBadResponse)
|
||||
}
|
||||
|
||||
var urls []string
|
||||
|
||||
appendOutput := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
urls = append(urls, value)
|
||||
}
|
||||
|
||||
switch output := prediction.Output.(type) {
|
||||
case string:
|
||||
appendOutput(output)
|
||||
case []any:
|
||||
for _, item := range output {
|
||||
if str, ok := item.(string); ok {
|
||||
appendOutput(str)
|
||||
}
|
||||
}
|
||||
case nil:
|
||||
// no output
|
||||
default:
|
||||
if str, ok := output.(fmt.Stringer); ok {
|
||||
appendOutput(str.String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(urls) == 0 {
|
||||
return nil, types.NewError(errors.New("replicate adaptor: empty prediction output"), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
var imageReq *dto.ImageRequest
|
||||
if info != nil {
|
||||
if req, ok := info.Request.(*dto.ImageRequest); ok {
|
||||
imageReq = req
|
||||
}
|
||||
}
|
||||
|
||||
wantsBase64 := imageReq != nil && strings.EqualFold(imageReq.ResponseFormat, "b64_json")
|
||||
|
||||
imageResponse := dto.ImageResponse{
|
||||
Created: common.GetTimestamp(),
|
||||
Data: make([]dto.ImageData, 0),
|
||||
}
|
||||
|
||||
if wantsBase64 {
|
||||
converted, convErr := downloadImagesToBase64(urls)
|
||||
if convErr != nil {
|
||||
return nil, types.NewError(convErr, types.ErrorCodeBadResponse)
|
||||
}
|
||||
for _, content := range converted {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: content})
|
||||
}
|
||||
} else {
|
||||
for _, url := range urls {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: url})
|
||||
}
|
||||
}
|
||||
|
||||
if len(imageResponse.Data) == 0 {
|
||||
return nil, types.NewError(errors.New("replicate adaptor: no usable image data"), types.ErrorCodeBadResponse)
|
||||
}
|
||||
|
||||
responseBytes, err := common.Marshal(imageResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(fmt.Errorf("replicate adaptor: encode response failed: %w", err), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(responseBytes)
|
||||
|
||||
usage := &dto.Usage{}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
|
||||
func downloadImagesToBase64(urls []string) ([]string, error) {
|
||||
results := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
if strings.TrimSpace(url) == "" {
|
||||
continue
|
||||
}
|
||||
_, data, err := service.GetImageFromUrl(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("replicate adaptor: failed to download image from %s: %w", url, err)
|
||||
}
|
||||
results = append(results, data)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func mapOpenAISizeToFlux(size string) (aspect string, width int, height int, ok bool) {
|
||||
parts := strings.Split(size, "x")
|
||||
if len(parts) != 2 {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
w, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
h, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err1 != nil || err2 != nil || w <= 0 || h <= 0 {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case w == h:
|
||||
return "1:1", 0, 0, true
|
||||
case w == 1792 && h == 1024:
|
||||
return "16:9", 0, 0, true
|
||||
case w == 1024 && h == 1792:
|
||||
return "9:16", 0, 0, true
|
||||
case w == 1536 && h == 1024:
|
||||
return "3:2", 0, 0, true
|
||||
case w == 1024 && h == 1536:
|
||||
return "2:3", 0, 0, true
|
||||
}
|
||||
|
||||
rw, rh := reduceRatio(w, h)
|
||||
ratioStr := fmt.Sprintf("%d:%d", rw, rh)
|
||||
switch ratioStr {
|
||||
case "1:1", "16:9", "9:16", "3:2", "2:3", "4:5", "5:4", "3:4", "4:3":
|
||||
return ratioStr, 0, 0, true
|
||||
}
|
||||
|
||||
width = normalizeFluxDimension(w)
|
||||
height = normalizeFluxDimension(h)
|
||||
return "custom", width, height, true
|
||||
}
|
||||
|
||||
func reduceRatio(w, h int) (int, int) {
|
||||
g := gcd(w, h)
|
||||
if g == 0 {
|
||||
return w, h
|
||||
}
|
||||
return w / g, h / g
|
||||
}
|
||||
|
||||
func gcd(a, b int) int {
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
if a < 0 {
|
||||
return -a
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func normalizeFluxDimension(value int) int {
|
||||
const (
|
||||
minDim = 256
|
||||
maxDim = 1440
|
||||
step = 32
|
||||
)
|
||||
if value < minDim {
|
||||
value = minDim
|
||||
}
|
||||
if value > maxDim {
|
||||
value = maxDim
|
||||
}
|
||||
remainder := value % step
|
||||
if remainder != 0 {
|
||||
if remainder >= step/2 {
|
||||
value += step - remainder
|
||||
} else {
|
||||
value -= remainder
|
||||
}
|
||||
}
|
||||
if value < minDim {
|
||||
value = minDim
|
||||
}
|
||||
if value > maxDim {
|
||||
value = maxDim
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func uploadFileFromForm(c *gin.Context, info *relaycommon.RelayInfo, fieldCandidates ...string) (string, error) {
|
||||
if info == nil {
|
||||
return "", errors.New("replicate adaptor: relay info is nil")
|
||||
}
|
||||
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: parse multipart form failed: %w", err)
|
||||
}
|
||||
mf = c.Request.MultipartForm
|
||||
}
|
||||
if mf == nil || len(mf.File) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if len(fieldCandidates) == 0 {
|
||||
fieldCandidates = []string{"image", "image[]", "image_prompt"}
|
||||
}
|
||||
|
||||
var fileHeader *multipart.FileHeader
|
||||
for _, key := range fieldCandidates {
|
||||
if files := mf.File[key]; len(files) > 0 {
|
||||
fileHeader = files[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if fileHeader == nil {
|
||||
for _, files := range mf.File {
|
||||
if len(files) > 0 {
|
||||
fileHeader = files[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if fileHeader == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: failed to open image file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
hdr := make(textproto.MIMEHeader)
|
||||
hdr.Set("Content-Disposition", fmt.Sprintf("form-data; name=\"content\"; filename=\"%s\"", fileHeader.Filename))
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
hdr.Set("Content-Type", contentType)
|
||||
|
||||
part, err := writer.CreatePart(hdr)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
return "", fmt.Errorf("replicate adaptor: create upload form failed: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
writer.Close()
|
||||
return "", fmt.Errorf("replicate adaptor: copy image content failed: %w", err)
|
||||
}
|
||||
formContentType := writer.FormDataContentType()
|
||||
writer.Close()
|
||||
|
||||
baseURL := info.ChannelBaseUrl
|
||||
if baseURL == "" {
|
||||
baseURL = constant.ChannelBaseURLs[constant.ChannelTypeReplicate]
|
||||
}
|
||||
uploadURL := relaycommon.GetFullRequestURL(baseURL, "/v1/files", info.ChannelType)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, uploadURL, &body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: create upload request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", formContentType)
|
||||
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
|
||||
resp, err := service.GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: upload image failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: read upload response failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("replicate adaptor: upload image failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
var uploadResp FileUploadResponse
|
||||
if err := common.Unmarshal(respBody, &uploadResp); err != nil {
|
||||
return "", fmt.Errorf("replicate adaptor: decode upload response failed: %w", err)
|
||||
}
|
||||
if uploadResp.Urls.Get == "" {
|
||||
return "", errors.New("replicate adaptor: upload response missing url")
|
||||
}
|
||||
return uploadResp.Urls.Get, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeneralOpenAIRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertOpenAIRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(*gin.Context, int, dto.RerankRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertRerankRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(*gin.Context, *relaycommon.RelayInfo, dto.EmbeddingRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertEmbeddingRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(*gin.Context, *relaycommon.RelayInfo, dto.AudioRequest) (io.Reader, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertAudioRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(*gin.Context, *relaycommon.RelayInfo, dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertOpenAIResponsesRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertClaudeRequest is not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
return nil, errors.New("replicate adaptor: ConvertGeminiRequest is not implemented")
|
||||
}
|
||||
12
relay/channel/replicate/constants.go
Normal file
12
relay/channel/replicate/constants.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package replicate
|
||||
|
||||
const (
|
||||
// ChannelName identifies the replicate channel.
|
||||
ChannelName = "replicate"
|
||||
// ModelFlux11Pro is the default image generation model supported by this channel.
|
||||
ModelFlux11Pro = "black-forest-labs/flux-1.1-pro"
|
||||
)
|
||||
|
||||
var ModelList = []string{
|
||||
ModelFlux11Pro,
|
||||
}
|
||||
19
relay/channel/replicate/dto.go
Normal file
19
relay/channel/replicate/dto.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package replicate
|
||||
|
||||
type PredictionResponse struct {
|
||||
Status string `json:"status"`
|
||||
Output any `json:"output"`
|
||||
Error *PredictionError `json:"error"`
|
||||
}
|
||||
|
||||
type PredictionError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type FileUploadResponse struct {
|
||||
Urls struct {
|
||||
Get string `json:"get"`
|
||||
} `json:"urls"`
|
||||
}
|
||||
@@ -31,8 +31,8 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not supported")
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertAudioRequest(c, info, request)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
@@ -65,16 +65,8 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == constant.RelayModeRerank {
|
||||
return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeEmbeddings {
|
||||
return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeChatCompletions {
|
||||
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeCompletions {
|
||||
return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil
|
||||
} else if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
return fmt.Sprintf("%s/v1/images/generations", info.ChannelBaseUrl), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
|
||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
@@ -103,7 +95,8 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoRequest(c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
@@ -118,21 +111,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeRerank:
|
||||
usage, err = siliconflowRerankHandler(c, info, resp)
|
||||
case constant.RelayModeEmbeddings:
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
case constant.RelayModeCompletions:
|
||||
fallthrough
|
||||
case constant.RelayModeChatCompletions:
|
||||
fallthrough
|
||||
case constant.RelayModeImagesGenerations:
|
||||
fallthrough
|
||||
default:
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
505
relay/channel/task/ali/adaptor.go
Normal file
505
relay/channel/task/ali/adaptor.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Request / Response structures
|
||||
// ============================
|
||||
|
||||
// AliVideoRequest 阿里通义万相视频生成请求
|
||||
type AliVideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input AliVideoInput `json:"input"`
|
||||
Parameters *AliVideoParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// AliVideoInput 视频输入参数
|
||||
type AliVideoInput struct {
|
||||
Prompt string `json:"prompt,omitempty"` // 文本提示词
|
||||
ImgURL string `json:"img_url,omitempty"` // 首帧图像URL或Base64(图生视频)
|
||||
FirstFrameURL string `json:"first_frame_url,omitempty"` // 首帧图片URL(首尾帧生视频)
|
||||
LastFrameURL string `json:"last_frame_url,omitempty"` // 尾帧图片URL(首尾帧生视频)
|
||||
AudioURL string `json:"audio_url,omitempty"` // 音频URL(wan2.5支持)
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"` // 反向提示词
|
||||
Template string `json:"template,omitempty"` // 视频特效模板
|
||||
}
|
||||
|
||||
// AliVideoParameters 视频参数
|
||||
type AliVideoParameters struct {
|
||||
Resolution string `json:"resolution,omitempty"` // 分辨率: 480P/720P/1080P(图生视频、首尾帧生视频)
|
||||
Size string `json:"size,omitempty"` // 尺寸: 如 "832*480"(文生视频)
|
||||
Duration int `json:"duration,omitempty"` // 时长: 3-10秒
|
||||
PromptExtend bool `json:"prompt_extend,omitempty"` // 是否开启prompt智能改写
|
||||
Watermark bool `json:"watermark,omitempty"` // 是否添加水印
|
||||
Audio *bool `json:"audio,omitempty"` // 是否添加音频(wan2.5)
|
||||
Seed int `json:"seed,omitempty"` // 随机数种子
|
||||
}
|
||||
|
||||
// AliVideoResponse 阿里通义万相响应
|
||||
type AliVideoResponse struct {
|
||||
Output AliVideoOutput `json:"output"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Usage *AliUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// AliVideoOutput 输出信息
|
||||
type AliVideoOutput struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
SubmitTime string `json:"submit_time,omitempty"`
|
||||
ScheduledTime string `json:"scheduled_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
OrigPrompt string `json:"orig_prompt,omitempty"`
|
||||
ActualPrompt string `json:"actual_prompt,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// AliUsage 使用统计
|
||||
type AliUsage struct {
|
||||
Duration int `json:"duration,omitempty"`
|
||||
VideoCount int `json:"video_count,omitempty"`
|
||||
SR int `json:"SR,omitempty"`
|
||||
}
|
||||
|
||||
type AliMetadata struct {
|
||||
// Input 相关
|
||||
AudioURL string `json:"audio_url,omitempty"` // 音频URL
|
||||
ImgURL string `json:"img_url,omitempty"` // 图片URL(图生视频)
|
||||
FirstFrameURL string `json:"first_frame_url,omitempty"` // 首帧图片URL(首尾帧生视频)
|
||||
LastFrameURL string `json:"last_frame_url,omitempty"` // 尾帧图片URL(首尾帧生视频)
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"` // 反向提示词
|
||||
Template string `json:"template,omitempty"` // 视频特效模板
|
||||
|
||||
// Parameters 相关
|
||||
Resolution *string `json:"resolution,omitempty"` // 分辨率: 480P/720P/1080P
|
||||
Size *string `json:"size,omitempty"` // 尺寸: 如 "832*480"
|
||||
Duration *int `json:"duration,omitempty"` // 时长
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"` // 是否开启prompt智能改写
|
||||
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印
|
||||
Audio *bool `json:"audio,omitempty"` // 是否添加音频
|
||||
Seed *int `json:"seed,omitempty"` // 随机数种子
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Adaptor implementation
|
||||
// ============================
|
||||
|
||||
type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
apiKey string
|
||||
baseURL string
|
||||
aliReq *AliVideoRequest
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
a.apiKey = info.ApiKey
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
// 阿里通义万相支持 JSON 格式,不使用 multipart
|
||||
var taskReq relaycommon.TaskSubmitReq
|
||||
if err := common.UnmarshalBodyReusable(c, &taskReq); err != nil {
|
||||
return service.TaskErrorWrapper(err, "unmarshal_task_request_failed", http.StatusBadRequest)
|
||||
}
|
||||
aliReq, err := a.convertToAliRequest(info, taskReq)
|
||||
if err != nil {
|
||||
return service.TaskErrorWrapper(err, "convert_to_ali_request_failed", http.StatusInternalServerError)
|
||||
}
|
||||
a.aliReq = aliReq
|
||||
logger.LogJson(c, "ali video request body", aliReq)
|
||||
return relaycommon.ValidateMultipartDirect(c, info)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/api/v1/services/aigc/video-generation/video-synthesis", a.baseURL), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers for Ali API
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-DashScope-Async", "enable") // 阿里异步任务必须设置
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
bodyBytes, err := common.Marshal(a.aliReq)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "marshal_ali_request_failed")
|
||||
}
|
||||
|
||||
return bytes.NewReader(bodyBytes), nil
|
||||
}
|
||||
|
||||
var (
|
||||
size480p = []string{
|
||||
"832*480",
|
||||
"480*832",
|
||||
"624*624",
|
||||
}
|
||||
size720p = []string{
|
||||
"1280*720",
|
||||
"720*1280",
|
||||
"960*960",
|
||||
"1088*832",
|
||||
"832*1088",
|
||||
}
|
||||
size1080p = []string{
|
||||
"1920*1080",
|
||||
"1080*1920",
|
||||
"1440*1440",
|
||||
"1632*1248",
|
||||
"1248*1632",
|
||||
}
|
||||
)
|
||||
|
||||
func sizeToResolution(size string) (string, error) {
|
||||
if lo.Contains(size480p, size) {
|
||||
return "480P", nil
|
||||
} else if lo.Contains(size720p, size) {
|
||||
return "720P", nil
|
||||
} else if lo.Contains(size1080p, size) {
|
||||
return "1080P", nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid size: %s", size)
|
||||
}
|
||||
|
||||
func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) {
|
||||
otherRatios := make(map[string]float64)
|
||||
aliRatios := map[string]map[string]float64{
|
||||
"wan2.5-t2v-preview": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
"1080P": 1 / 0.3,
|
||||
},
|
||||
"wan2.2-t2v-plus": {
|
||||
"480P": 1,
|
||||
"1080P": 0.7 / 0.14,
|
||||
},
|
||||
"wan2.5-i2v-preview": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
"1080P": 1 / 0.3,
|
||||
},
|
||||
"wan2.2-i2v-plus": {
|
||||
"480P": 1,
|
||||
"1080P": 0.7 / 0.14,
|
||||
},
|
||||
"wan2.2-kf2v-flash": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
"1080P": 4.8,
|
||||
},
|
||||
"wan2.2-i2v-flash": {
|
||||
"480P": 1,
|
||||
"720P": 2,
|
||||
},
|
||||
"wan2.2-s2v": {
|
||||
"480P": 1,
|
||||
"720P": 0.9 / 0.5,
|
||||
},
|
||||
}
|
||||
var resolution string
|
||||
|
||||
// size match
|
||||
if aliReq.Parameters.Size != "" {
|
||||
toResolution, err := sizeToResolution(aliReq.Parameters.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolution = toResolution
|
||||
} else {
|
||||
resolution = strings.ToUpper(aliReq.Parameters.Resolution)
|
||||
if !strings.HasSuffix(resolution, "P") {
|
||||
resolution = resolution + "P"
|
||||
}
|
||||
}
|
||||
if otherRatio, ok := aliRatios[aliReq.Model]; ok {
|
||||
if ratio, ok := otherRatio[resolution]; ok {
|
||||
otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio
|
||||
}
|
||||
}
|
||||
return otherRatios, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relaycommon.TaskSubmitReq) (*AliVideoRequest, error) {
|
||||
aliReq := &AliVideoRequest{
|
||||
Model: req.Model,
|
||||
Input: AliVideoInput{
|
||||
Prompt: req.Prompt,
|
||||
ImgURL: req.InputReference,
|
||||
},
|
||||
Parameters: &AliVideoParameters{
|
||||
PromptExtend: true, // 默认开启智能改写
|
||||
Watermark: false,
|
||||
},
|
||||
}
|
||||
|
||||
// 处理分辨率映射
|
||||
if req.Size != "" {
|
||||
// text to video size must be contained *
|
||||
if strings.Contains(req.Model, "t2v") && !strings.Contains(req.Size, "*") {
|
||||
return nil, fmt.Errorf("invalid size: %s, example: %s", req.Size, "1920*1080")
|
||||
}
|
||||
if strings.Contains(req.Size, "*") {
|
||||
aliReq.Parameters.Size = req.Size
|
||||
} else {
|
||||
resolution := strings.ToUpper(req.Size)
|
||||
// 支持 480p, 720p, 1080p 或 480P, 720P, 1080P
|
||||
if !strings.HasSuffix(resolution, "P") {
|
||||
resolution = resolution + "P"
|
||||
}
|
||||
aliReq.Parameters.Resolution = resolution
|
||||
}
|
||||
} else {
|
||||
// 根据模型设置默认分辨率
|
||||
if strings.Contains(req.Model, "t2v") { // image to video
|
||||
if strings.HasPrefix(req.Model, "wan2.5") {
|
||||
aliReq.Parameters.Size = "1920*1080"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2") {
|
||||
aliReq.Parameters.Size = "1920*1080"
|
||||
} else {
|
||||
aliReq.Parameters.Size = "1280*720"
|
||||
}
|
||||
} else {
|
||||
if strings.HasPrefix(req.Model, "wan2.5") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-flash") {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
} else if strings.HasPrefix(req.Model, "wan2.2-i2v-plus") {
|
||||
aliReq.Parameters.Resolution = "1080P"
|
||||
} else {
|
||||
aliReq.Parameters.Resolution = "720P"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理时长
|
||||
if req.Duration > 0 {
|
||||
aliReq.Parameters.Duration = req.Duration
|
||||
} else if req.Seconds != "" {
|
||||
seconds, err := strconv.Atoi(req.Seconds)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "convert seconds to int failed")
|
||||
} else {
|
||||
aliReq.Parameters.Duration = seconds
|
||||
}
|
||||
} else {
|
||||
aliReq.Parameters.Duration = 5 // 默认5秒
|
||||
}
|
||||
|
||||
// 从 metadata 中提取额外参数
|
||||
if req.Metadata != nil {
|
||||
if metadataBytes, err := common.Marshal(req.Metadata); err == nil {
|
||||
err = common.Unmarshal(metadataBytes, aliReq)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
||||
}
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "marshal metadata failed")
|
||||
}
|
||||
}
|
||||
|
||||
if aliReq.Model != req.Model {
|
||||
return nil, errors.New("can't change model with metadata")
|
||||
}
|
||||
|
||||
info.PriceData.OtherRatios = map[string]float64{
|
||||
"seconds": float64(aliReq.Parameters.Duration),
|
||||
}
|
||||
|
||||
ratios, err := ProcessAliOtherRatios(aliReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for s, f := range ratios {
|
||||
info.PriceData.OtherRatios[s] = f
|
||||
}
|
||||
|
||||
return aliReq, nil
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// 解析阿里响应
|
||||
var aliResp AliVideoResponse
|
||||
if err := common.Unmarshal(responseBody, &aliResp); err != nil {
|
||||
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if aliResp.Code != "" {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf("%s: %s", aliResp.Code, aliResp.Message), "ali_api_error", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if aliResp.Output.TaskID == "" {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 OpenAI 格式响应
|
||||
openAIResp := dto.NewOpenAIVideo()
|
||||
openAIResp.ID = aliResp.Output.TaskID
|
||||
openAIResp.Model = c.GetString("model")
|
||||
if openAIResp.Model == "" && info != nil {
|
||||
openAIResp.Model = info.OriginModelName
|
||||
}
|
||||
openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus)
|
||||
openAIResp.CreatedAt = common.GetTimestamp()
|
||||
|
||||
// 返回 OpenAI 格式
|
||||
c.JSON(http.StatusOK, openAIResp)
|
||||
|
||||
return aliResp.Output.TaskID, responseBody, nil
|
||||
}
|
||||
|
||||
// FetchTask 查询任务状态
|
||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
|
||||
taskID, ok := body["task_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s/api/v1/tasks/%s", baseUrl, taskID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
|
||||
// ParseTaskResult 解析任务结果
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
var aliResp AliVideoResponse
|
||||
if err := common.Unmarshal(respBody, &aliResp); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal task result failed")
|
||||
}
|
||||
|
||||
taskResult := relaycommon.TaskInfo{
|
||||
Code: 0,
|
||||
}
|
||||
|
||||
// 状态映射
|
||||
switch aliResp.Output.TaskStatus {
|
||||
case "PENDING":
|
||||
taskResult.Status = model.TaskStatusQueued
|
||||
case "RUNNING":
|
||||
taskResult.Status = model.TaskStatusInProgress
|
||||
case "SUCCEEDED":
|
||||
taskResult.Status = model.TaskStatusSuccess
|
||||
// 阿里直接返回视频URL,不需要额外的代理端点
|
||||
taskResult.Url = aliResp.Output.VideoURL
|
||||
case "FAILED", "CANCELED", "UNKNOWN":
|
||||
taskResult.Status = model.TaskStatusFailure
|
||||
if aliResp.Message != "" {
|
||||
taskResult.Reason = aliResp.Message
|
||||
} else if aliResp.Output.Message != "" {
|
||||
taskResult.Reason = fmt.Sprintf("task failed, code: %s , message: %s", aliResp.Output.Code, aliResp.Output.Message)
|
||||
} else {
|
||||
taskResult.Reason = "task failed"
|
||||
}
|
||||
default:
|
||||
taskResult.Status = model.TaskStatusQueued
|
||||
}
|
||||
|
||||
return &taskResult, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
|
||||
var aliResp AliVideoResponse
|
||||
if err := common.Unmarshal(task.Data, &aliResp); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal ali response failed")
|
||||
}
|
||||
|
||||
openAIResp := dto.NewOpenAIVideo()
|
||||
openAIResp.ID = task.TaskID
|
||||
openAIResp.Status = convertAliStatus(aliResp.Output.TaskStatus)
|
||||
openAIResp.Model = task.Properties.OriginModelName
|
||||
openAIResp.SetProgressStr(task.Progress)
|
||||
openAIResp.CreatedAt = task.CreatedAt
|
||||
openAIResp.CompletedAt = task.UpdatedAt
|
||||
|
||||
// 设置视频URL(核心字段)
|
||||
openAIResp.SetMetadata("url", aliResp.Output.VideoURL)
|
||||
|
||||
// 错误处理
|
||||
if aliResp.Code != "" {
|
||||
openAIResp.Error = &dto.OpenAIVideoError{
|
||||
Code: aliResp.Code,
|
||||
Message: aliResp.Message,
|
||||
}
|
||||
} else if aliResp.Output.Code != "" {
|
||||
openAIResp.Error = &dto.OpenAIVideoError{
|
||||
Code: aliResp.Output.Code,
|
||||
Message: aliResp.Output.Message,
|
||||
}
|
||||
}
|
||||
|
||||
return common.Marshal(openAIResp)
|
||||
}
|
||||
|
||||
func convertAliStatus(aliStatus string) string {
|
||||
switch aliStatus {
|
||||
case "PENDING":
|
||||
return dto.VideoStatusQueued
|
||||
case "RUNNING":
|
||||
return dto.VideoStatusInProgress
|
||||
case "SUCCEEDED":
|
||||
return dto.VideoStatusCompleted
|
||||
case "FAILED", "CANCELED", "UNKNOWN":
|
||||
return dto.VideoStatusFailed
|
||||
default:
|
||||
return dto.VideoStatusUnknown
|
||||
}
|
||||
}
|
||||
11
relay/channel/task/ali/constants.go
Normal file
11
relay/channel/task/ali/constants.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package ali
|
||||
|
||||
var ModelList = []string{
|
||||
"wan2.5-i2v-preview", // 万相2.5 preview(有声视频)推荐
|
||||
"wan2.2-i2v-flash", // 万相2.2极速版(无声视频)
|
||||
"wan2.2-i2v-plus", // 万相2.2专业版(无声视频)
|
||||
"wanx2.1-i2v-plus", // 万相2.1专业版(无声视频)
|
||||
"wanx2.1-i2v-turbo", // 万相2.1极速版(无声视频)
|
||||
}
|
||||
|
||||
var ChannelName = "ali"
|
||||
324
relay/channel/task/gemini/adaptor.go
Normal file
324
relay/channel/task/gemini/adaptor.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Request / Response structures
|
||||
// ============================
|
||||
|
||||
// GeminiVideoGenerationConfig represents the video generation configuration
|
||||
// Based on: https://ai.google.dev/gemini-api/docs/video
|
||||
type GeminiVideoGenerationConfig struct {
|
||||
AspectRatio string `json:"aspectRatio,omitempty"` // "16:9" or "9:16"
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"` // 4, 6, or 8 (as number)
|
||||
NegativePrompt string `json:"negativePrompt,omitempty"` // unwanted elements
|
||||
PersonGeneration string `json:"personGeneration,omitempty"` // "allow_all" for text-to-video, "allow_adult" for image-to-video
|
||||
Resolution string `json:"resolution,omitempty"` // video resolution
|
||||
}
|
||||
|
||||
// GeminiVideoRequest represents a single video generation instance
|
||||
type GeminiVideoRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// GeminiVideoPayload represents the complete video generation request payload
|
||||
type GeminiVideoPayload struct {
|
||||
Instances []GeminiVideoRequest `json:"instances"`
|
||||
Parameters GeminiVideoGenerationConfig `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type submitResponse struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type operationVideo struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
|
||||
type operationResponse struct {
|
||||
Name string `json:"name"`
|
||||
Done bool `json:"done"`
|
||||
Response struct {
|
||||
Type string `json:"@type"`
|
||||
RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
|
||||
Videos []operationVideo `json:"videos"`
|
||||
BytesBase64Encoded string `json:"bytesBase64Encoded"`
|
||||
Encoding string `json:"encoding"`
|
||||
Video string `json:"video"`
|
||||
GenerateVideoResponse struct {
|
||||
GeneratedSamples []struct {
|
||||
Video struct {
|
||||
URI string `json:"uri"`
|
||||
} `json:"video"`
|
||||
} `json:"generatedSamples"`
|
||||
} `json:"generateVideoResponse"`
|
||||
} `json:"response"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Adaptor implementation
|
||||
// ============================
|
||||
|
||||
type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
apiKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
a.apiKey = info.ApiKey
|
||||
}
|
||||
|
||||
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
// Use the standard validation method for TaskSubmitReq
|
||||
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate)
|
||||
}
|
||||
|
||||
// BuildRequestURL constructs the upstream URL.
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
modelName := info.OriginModelName
|
||||
version := model_setting.GetGeminiVersionSetting(modelName)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s/%s/models/%s:predictLongRunning",
|
||||
a.baseURL,
|
||||
version,
|
||||
modelName,
|
||||
), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("x-goog-api-key", a.apiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Gemini specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, ok := c.Get("task_request")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
}
|
||||
req, ok := v.(relaycommon.TaskSubmitReq)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected task_request type")
|
||||
}
|
||||
|
||||
// Create structured video generation request
|
||||
body := GeminiVideoPayload{
|
||||
Instances: []GeminiVideoRequest{
|
||||
{Prompt: req.Prompt},
|
||||
},
|
||||
Parameters: GeminiVideoGenerationConfig{},
|
||||
}
|
||||
|
||||
metadata := req.Metadata
|
||||
medaBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "metadata marshal metadata failed")
|
||||
}
|
||||
err = json.Unmarshal(medaBytes, &body.Parameters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response, returns taskID etc.
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var s submitResponse
|
||||
if err := json.Unmarshal(responseBody, &s); err != nil {
|
||||
return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
|
||||
}
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
|
||||
}
|
||||
taskID = encodeLocalTaskID(s.Name)
|
||||
ov := dto.NewOpenAIVideo()
|
||||
ov.ID = taskID
|
||||
ov.TaskID = taskID
|
||||
ov.CreatedAt = time.Now().Unix()
|
||||
ov.Model = info.OriginModelName
|
||||
c.JSON(http.StatusOK, ov)
|
||||
return taskID, responseBody, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return []string{"veo-3.0-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"}
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
return "gemini"
|
||||
}
|
||||
|
||||
// FetchTask fetch task status
|
||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
|
||||
taskID, ok := body["task_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
|
||||
upstreamName, err := decodeLocalTaskID(taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode task_id failed: %w", err)
|
||||
}
|
||||
|
||||
// For Gemini API, we use GET request to the operations endpoint
|
||||
version := model_setting.GetGeminiVersionSetting("default")
|
||||
url := fmt.Sprintf("%s/%s/%s", baseUrl, version, upstreamName)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("x-goog-api-key", key)
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
var op operationResponse
|
||||
if err := json.Unmarshal(respBody, &op); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
|
||||
}
|
||||
|
||||
ti := &relaycommon.TaskInfo{}
|
||||
|
||||
if op.Error.Message != "" {
|
||||
ti.Status = model.TaskStatusFailure
|
||||
ti.Reason = op.Error.Message
|
||||
ti.Progress = "100%"
|
||||
return ti, nil
|
||||
}
|
||||
|
||||
if !op.Done {
|
||||
ti.Status = model.TaskStatusInProgress
|
||||
ti.Progress = "50%"
|
||||
return ti, nil
|
||||
}
|
||||
|
||||
ti.Status = model.TaskStatusSuccess
|
||||
ti.Progress = "100%"
|
||||
|
||||
taskID := encodeLocalTaskID(op.Name)
|
||||
ti.TaskID = taskID
|
||||
ti.Url = fmt.Sprintf("%s/v1/videos/%s/content", system_setting.ServerAddress, taskID)
|
||||
|
||||
// Extract URL from generateVideoResponse if available
|
||||
if len(op.Response.GenerateVideoResponse.GeneratedSamples) > 0 {
|
||||
if uri := op.Response.GenerateVideoResponse.GeneratedSamples[0].Video.URI; uri != "" {
|
||||
ti.RemoteUrl = uri
|
||||
}
|
||||
}
|
||||
|
||||
return ti, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
|
||||
upstreamName, err := decodeLocalTaskID(task.TaskID)
|
||||
if err != nil {
|
||||
upstreamName = ""
|
||||
}
|
||||
modelName := extractModelFromOperationName(upstreamName)
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = "veo-3.0-generate-001"
|
||||
}
|
||||
|
||||
video := dto.NewOpenAIVideo()
|
||||
video.ID = task.TaskID
|
||||
video.Model = modelName
|
||||
video.Status = task.Status.ToVideoStatus()
|
||||
video.SetProgressStr(task.Progress)
|
||||
video.CreatedAt = task.CreatedAt
|
||||
if task.FinishTime > 0 {
|
||||
video.CompletedAt = task.FinishTime
|
||||
} else if task.UpdatedAt > 0 {
|
||||
video.CompletedAt = task.UpdatedAt
|
||||
}
|
||||
|
||||
return common.Marshal(video)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// helpers
|
||||
// ============================
|
||||
|
||||
func encodeLocalTaskID(name string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(name))
|
||||
}
|
||||
|
||||
func decodeLocalTaskID(local string) (string, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
|
||||
|
||||
func extractModelFromOperationName(name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if m := modelRe.FindStringSubmatch(name); len(m) == 2 {
|
||||
return m[1]
|
||||
}
|
||||
if idx := strings.Index(name, "models/"); idx >= 0 {
|
||||
s := name[idx+len("models/"):]
|
||||
if p := strings.Index(s, "/operations/"); p > 0 {
|
||||
return s[:p]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
297
relay/channel/task/hailuo/adaptor.go
Normal file
297
relay/channel/task/hailuo/adaptor.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package hailuo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
)
|
||||
|
||||
// https://platform.minimaxi.com/docs/api-reference/video-generation-intro
|
||||
type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
apiKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
a.apiKey = info.ApiKey
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s%s", a.baseURL, TextToVideoEndpoint), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
}
|
||||
req, ok := v.(relaycommon.TaskSubmitReq)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid request type in context")
|
||||
}
|
||||
|
||||
body, err := a.convertToRequestPayload(&req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "convert request payload failed")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var hResp VideoResponse
|
||||
if err := json.Unmarshal(responseBody, &hResp); err != nil {
|
||||
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if hResp.BaseResp.StatusCode != StatusSuccess {
|
||||
taskErr = service.TaskErrorWrapper(
|
||||
fmt.Errorf("hailuo api error: %s", hResp.BaseResp.StatusMsg),
|
||||
strconv.Itoa(hResp.BaseResp.StatusCode),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ov := dto.NewOpenAIVideo()
|
||||
ov.ID = hResp.TaskID
|
||||
ov.TaskID = hResp.TaskID
|
||||
ov.CreatedAt = time.Now().Unix()
|
||||
ov.Model = info.OriginModelName
|
||||
|
||||
c.JSON(http.StatusOK, ov)
|
||||
return hResp.TaskID, responseBody, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
|
||||
taskID, ok := body["task_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*VideoRequest, error) {
|
||||
modelConfig := GetModelConfig(req.Model)
|
||||
duration := DefaultDuration
|
||||
if req.Duration > 0 {
|
||||
duration = req.Duration
|
||||
}
|
||||
resolution := modelConfig.DefaultResolution
|
||||
if req.Size != "" {
|
||||
resolution = a.parseResolutionFromSize(req.Size, modelConfig)
|
||||
}
|
||||
|
||||
videoRequest := &VideoRequest{
|
||||
Model: req.Model,
|
||||
Prompt: req.Prompt,
|
||||
Duration: &duration,
|
||||
Resolution: resolution,
|
||||
}
|
||||
if err := req.UnmarshalMetadata(&videoRequest); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal metadata to video request failed")
|
||||
}
|
||||
|
||||
return videoRequest, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) parseResolutionFromSize(size string, modelConfig ModelConfig) string {
|
||||
switch {
|
||||
case strings.Contains(size, "1080"):
|
||||
return Resolution1080P
|
||||
case strings.Contains(size, "768"):
|
||||
return Resolution768P
|
||||
case strings.Contains(size, "720"):
|
||||
return Resolution720P
|
||||
case strings.Contains(size, "512"):
|
||||
return Resolution512P
|
||||
default:
|
||||
return modelConfig.DefaultResolution
|
||||
}
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
resTask := QueryTaskResponse{}
|
||||
if err := json.Unmarshal(respBody, &resTask); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal task result failed")
|
||||
}
|
||||
|
||||
taskResult := relaycommon.TaskInfo{}
|
||||
|
||||
if resTask.BaseResp.StatusCode == StatusSuccess {
|
||||
taskResult.Code = 0
|
||||
} else {
|
||||
taskResult.Code = resTask.BaseResp.StatusCode
|
||||
taskResult.Reason = resTask.BaseResp.StatusMsg
|
||||
taskResult.Status = model.TaskStatusFailure
|
||||
taskResult.Progress = "100%"
|
||||
}
|
||||
|
||||
switch resTask.Status {
|
||||
case TaskStatusPreparing, TaskStatusQueueing, TaskStatusProcessing:
|
||||
taskResult.Status = model.TaskStatusInProgress
|
||||
taskResult.Progress = "30%"
|
||||
if resTask.Status == TaskStatusProcessing {
|
||||
taskResult.Progress = "50%"
|
||||
}
|
||||
case TaskStatusSuccess:
|
||||
taskResult.Status = model.TaskStatusSuccess
|
||||
taskResult.Progress = "100%"
|
||||
taskResult.Url = a.buildVideoURL(resTask.TaskID, resTask.FileID)
|
||||
case TaskStatusFailed:
|
||||
taskResult.Status = model.TaskStatusFailure
|
||||
taskResult.Progress = "100%"
|
||||
if taskResult.Reason == "" {
|
||||
taskResult.Reason = "task failed"
|
||||
}
|
||||
default:
|
||||
taskResult.Status = model.TaskStatusInProgress
|
||||
taskResult.Progress = "30%"
|
||||
}
|
||||
|
||||
return &taskResult, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) {
|
||||
var hailuoResp QueryTaskResponse
|
||||
if err := json.Unmarshal(originTask.Data, &hailuoResp); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal hailuo task data failed")
|
||||
}
|
||||
|
||||
openAIVideo := originTask.ToOpenAIVideo()
|
||||
if hailuoResp.BaseResp.StatusCode != StatusSuccess {
|
||||
openAIVideo.Error = &dto.OpenAIVideoError{
|
||||
Message: hailuoResp.BaseResp.StatusMsg,
|
||||
Code: strconv.Itoa(hailuoResp.BaseResp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
jsonData, err := common.Marshal(openAIVideo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "marshal openai video failed")
|
||||
}
|
||||
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) buildVideoURL(_, fileID string) string {
|
||||
if a.apiKey == "" || a.baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", a.baseURL, fileID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
|
||||
resp, err := service.GetHttpClient().Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var retrieveResp RetrieveFileResponse
|
||||
if err := json.Unmarshal(responseBody, &retrieveResp); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if retrieveResp.BaseResp.StatusCode != StatusSuccess {
|
||||
return ""
|
||||
}
|
||||
|
||||
return retrieveResp.File.DownloadURL
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsInt(slice []int, item int) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
52
relay/channel/task/hailuo/constants.go
Normal file
52
relay/channel/task/hailuo/constants.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package hailuo
|
||||
|
||||
const (
|
||||
ChannelName = "hailuo-video"
|
||||
)
|
||||
|
||||
var ModelList = []string{
|
||||
"MiniMax-Hailuo-2.3",
|
||||
"MiniMax-Hailuo-2.3-Fast",
|
||||
"MiniMax-Hailuo-02",
|
||||
"T2V-01-Director",
|
||||
"T2V-01",
|
||||
"I2V-01-Director",
|
||||
"I2V-01-live",
|
||||
"I2V-01",
|
||||
"S2V-01",
|
||||
}
|
||||
|
||||
const (
|
||||
TextToVideoEndpoint = "/v1/video_generation"
|
||||
QueryTaskEndpoint = "/v1/query/video_generation"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusSuccess = 0
|
||||
StatusRateLimit = 1002
|
||||
StatusAuthFailed = 1004
|
||||
StatusNoBalance = 1008
|
||||
StatusSensitive = 1026
|
||||
StatusParamError = 2013
|
||||
StatusInvalidKey = 2049
|
||||
)
|
||||
|
||||
const (
|
||||
TaskStatusPreparing = "Preparing"
|
||||
TaskStatusQueueing = "Queueing"
|
||||
TaskStatusProcessing = "Processing"
|
||||
TaskStatusSuccess = "Success"
|
||||
TaskStatusFailed = "Fail"
|
||||
)
|
||||
|
||||
const (
|
||||
Resolution512P = "512P"
|
||||
Resolution720P = "720P"
|
||||
Resolution768P = "768P"
|
||||
Resolution1080P = "1080P"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDuration = 6
|
||||
DefaultResolution = Resolution720P
|
||||
)
|
||||
170
relay/channel/task/hailuo/models.go
Normal file
170
relay/channel/task/hailuo/models.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package hailuo
|
||||
|
||||
type SubjectReference struct {
|
||||
Type string `json:"type"` // Subject type, currently only supports "character"
|
||||
Image []string `json:"image"` // Array of subject reference images (currently only supports single image)
|
||||
}
|
||||
|
||||
type VideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
|
||||
FastPretreatment *bool `json:"fast_pretreatment,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
|
||||
FirstFrameImage string `json:"first_frame_image,omitempty"` // For image-to-video and start-end-to-video
|
||||
LastFrameImage string `json:"last_frame_image,omitempty"` // For start-end-to-video
|
||||
SubjectReference []SubjectReference `json:"subject_reference,omitempty"` // For subject-reference-to-video
|
||||
}
|
||||
|
||||
type VideoResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
BaseResp BaseResp `json:"base_resp"`
|
||||
}
|
||||
|
||||
type BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
}
|
||||
|
||||
type QueryTaskRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
type QueryTaskResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
VideoWidth int `json:"video_width,omitempty"`
|
||||
VideoHeight int `json:"video_height,omitempty"`
|
||||
BaseResp BaseResp `json:"base_resp"`
|
||||
}
|
||||
|
||||
type ErrorInfo struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
}
|
||||
|
||||
type TaskStatusInfo struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
ErrorMsg string `json:"error_msg,omitempty"`
|
||||
}
|
||||
|
||||
type ModelConfig struct {
|
||||
Name string
|
||||
DefaultResolution string
|
||||
SupportedDurations []int
|
||||
SupportedResolutions []string
|
||||
HasPromptOptimizer bool
|
||||
HasFastPretreatment bool
|
||||
}
|
||||
|
||||
type RetrieveFileResponse struct {
|
||||
File FileObject `json:"file"`
|
||||
BaseResp BaseResp `json:"base_resp"`
|
||||
}
|
||||
|
||||
type FileObject struct {
|
||||
FileID int64 `json:"file_id"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Filename string `json:"filename"`
|
||||
Purpose string `json:"purpose"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
func GetModelConfig(model string) ModelConfig {
|
||||
configs := map[string]ModelConfig{
|
||||
"MiniMax-Hailuo-2.3": {
|
||||
Name: "MiniMax-Hailuo-2.3",
|
||||
DefaultResolution: Resolution768P,
|
||||
SupportedDurations: []int{6, 10},
|
||||
SupportedResolutions: []string{Resolution768P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: true,
|
||||
},
|
||||
"MiniMax-Hailuo-2.3-Fast": {
|
||||
Name: "MiniMax-Hailuo-2.3-Fast",
|
||||
DefaultResolution: Resolution768P,
|
||||
SupportedDurations: []int{6, 10},
|
||||
SupportedResolutions: []string{Resolution768P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: true,
|
||||
},
|
||||
"MiniMax-Hailuo-02": {
|
||||
Name: "MiniMax-Hailuo-02",
|
||||
DefaultResolution: Resolution768P,
|
||||
SupportedDurations: []int{6, 10},
|
||||
SupportedResolutions: []string{Resolution512P, Resolution768P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: true,
|
||||
},
|
||||
"T2V-01-Director": {
|
||||
Name: "T2V-01-Director",
|
||||
DefaultResolution: Resolution768P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution768P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
"T2V-01": {
|
||||
Name: "T2V-01",
|
||||
DefaultResolution: Resolution720P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution720P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
"I2V-01-Director": {
|
||||
Name: "I2V-01-Director",
|
||||
DefaultResolution: Resolution720P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution720P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
"I2V-01-live": {
|
||||
Name: "I2V-01-live",
|
||||
DefaultResolution: Resolution720P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution720P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
"I2V-01": {
|
||||
Name: "I2V-01",
|
||||
DefaultResolution: Resolution720P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution720P, Resolution1080P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
"S2V-01": {
|
||||
Name: "S2V-01",
|
||||
DefaultResolution: Resolution720P,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{Resolution720P},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
},
|
||||
}
|
||||
|
||||
if config, exists := configs[model]; exists {
|
||||
return config
|
||||
}
|
||||
|
||||
return ModelConfig{
|
||||
Name: model,
|
||||
DefaultResolution: DefaultResolution,
|
||||
SupportedDurations: []int{6},
|
||||
SupportedResolutions: []string{DefaultResolution},
|
||||
HasPromptOptimizer: true,
|
||||
HasFastPretreatment: false,
|
||||
}
|
||||
}
|
||||
@@ -406,12 +406,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
|
||||
// 即梦视频3.0 ReqKey转换
|
||||
// https://www.volcengine.com/docs/85621/1792707
|
||||
if strings.Contains(r.ReqKey, "jimeng_v30") {
|
||||
if len(req.Images) > 1 {
|
||||
if r.ReqKey == "jimeng_v30_pro" {
|
||||
// 3.0 pro只有固定的jimeng_ti2v_v30_pro
|
||||
r.ReqKey = "jimeng_ti2v_v30_pro"
|
||||
} else if len(req.Images) > 1 {
|
||||
// 多张图片:首尾帧生成
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
|
||||
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p")
|
||||
} else if len(req.Images) == 1 {
|
||||
// 单张图片:图生视频
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
|
||||
r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p")
|
||||
} else {
|
||||
// 无图片:文生视频
|
||||
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -302,6 +303,29 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
||||
return ti, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) {
|
||||
upstreamName, err := decodeLocalTaskID(task.TaskID)
|
||||
if err != nil {
|
||||
upstreamName = ""
|
||||
}
|
||||
modelName := extractModelFromOperationName(upstreamName)
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = "veo-3.0-generate-001"
|
||||
}
|
||||
v := dto.NewOpenAIVideo()
|
||||
v.ID = task.TaskID
|
||||
v.Model = modelName
|
||||
v.Status = task.Status.ToVideoStatus()
|
||||
v.SetProgressStr(task.Progress)
|
||||
v.CreatedAt = task.CreatedAt
|
||||
v.CompletedAt = task.UpdatedAt
|
||||
if strings.HasPrefix(task.FailReason, "data:") && len(task.FailReason) > 0 {
|
||||
v.SetMetadata("url", task.FailReason)
|
||||
}
|
||||
|
||||
return common.Marshal(v)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// helpers
|
||||
// ============================
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -82,10 +83,32 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||||
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
|
||||
if err := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := relaycommon.GetTaskRequest(c)
|
||||
if err != nil {
|
||||
return service.TaskErrorWrapper(err, "get_task_request_failed", http.StatusBadRequest)
|
||||
}
|
||||
action := constant.TaskActionTextGenerate
|
||||
if meatAction, ok := req.Metadata["action"]; ok {
|
||||
action, _ = meatAction.(string)
|
||||
} else if req.HasImage() {
|
||||
action = constant.TaskActionGenerate
|
||||
if info.ChannelType == constant.ChannelTypeVidu {
|
||||
// vidu 增加 首尾帧生视频和参考图生视频
|
||||
if len(req.Images) == 2 {
|
||||
action = constant.TaskActionFirstTailGenerate
|
||||
} else if len(req.Images) > 2 {
|
||||
action = constant.TaskActionReferenceGenerate
|
||||
}
|
||||
}
|
||||
}
|
||||
info.Action = action
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("request not found in context")
|
||||
@@ -97,8 +120,11 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(body.Images) == 0 {
|
||||
c.Set("action", constant.TaskActionTextGenerate)
|
||||
if info.Action == constant.TaskActionReferenceGenerate {
|
||||
if strings.Contains(body.Model, "viduq2") {
|
||||
// 参考图生视频只能用 viduq2 模型, 不能带有pro或turbo后缀 https://platform.vidu.cn/docs/reference-to-video
|
||||
body.Model = "viduq2"
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
@@ -131,9 +157,6 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
if action := c.GetString("action"); action != "" {
|
||||
info.Action = action
|
||||
}
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
@@ -185,7 +208,7 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return []string{"viduq1", "vidu2.0", "vidu1.5"}
|
||||
return []string{"viduq2", "viduq1", "vidu2.0", "vidu1.5"}
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
|
||||
@@ -130,7 +130,7 @@ func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *htt
|
||||
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
return service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens), nil
|
||||
return service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens), nil
|
||||
}
|
||||
|
||||
func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
|
||||
@@ -76,7 +76,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
if strings.HasPrefix(info.UpstreamModelName, "claude") {
|
||||
a.RequestMode = RequestModeClaude
|
||||
} else if strings.Contains(info.UpstreamModelName, "llama") {
|
||||
} else if strings.Contains(info.UpstreamModelName, "llama") ||
|
||||
// open source models
|
||||
strings.Contains(info.UpstreamModelName, "-maas") {
|
||||
a.RequestMode = RequestModeLlama
|
||||
} else {
|
||||
a.RequestMode = RequestModeGemini
|
||||
@@ -168,7 +170,8 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
suffix := ""
|
||||
if a.RequestMode == RequestModeGemini {
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
|
||||
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
|
||||
// 新增逻辑:处理 -thinking-<budget> 格式
|
||||
if strings.Contains(info.UpstreamModelName, "-thinking-") {
|
||||
parts := strings.Split(info.UpstreamModelName, "-thinking-")
|
||||
@@ -219,6 +222,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
if a.AccountCredentials.ProjectID != "" {
|
||||
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
|
||||
}
|
||||
if strings.Contains(info.UpstreamModelName, "claude") {
|
||||
claude.CommonClaudeHeadersOperation(c, req, info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -290,7 +296,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
info.UpstreamModelName = claudeReq.Model
|
||||
return vertexClaudeReq, nil
|
||||
} else if a.RequestMode == RequestModeGemini {
|
||||
geminiRequest, err := gemini.CovertGemini2OpenAI(c, *request, info)
|
||||
geminiRequest, err := gemini.CovertOpenAI2Gemini(c, *request, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,11 +16,20 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
contextKeyTTSRequest = "volcengine_tts_request"
|
||||
contextKeyResponseFormat = "response_format"
|
||||
DoubaoCodingPlan = "doubao-coding-plan"
|
||||
DoubaoCodingPlanClaudeBaseURL = "https://ark.cn-beijing.volces.com/api/coding"
|
||||
DoubaoCodingPlanOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
)
|
||||
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
@@ -37,136 +44,175 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
if info.RelayMode != constant.RelayModeAudioSpeech {
|
||||
return nil, errors.New("unsupported audio relay mode")
|
||||
}
|
||||
|
||||
appID, token, err := parseVolcengineAuth(info.ApiKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
voiceType := mapVoiceType(request.Voice)
|
||||
speedRatio := request.Speed
|
||||
encoding := mapEncoding(request.ResponseFormat)
|
||||
|
||||
c.Set(contextKeyResponseFormat, encoding)
|
||||
|
||||
volcRequest := VolcengineTTSRequest{
|
||||
App: VolcengineTTSApp{
|
||||
AppID: appID,
|
||||
Token: token,
|
||||
Cluster: "volcano_tts",
|
||||
},
|
||||
User: VolcengineTTSUser{
|
||||
UID: "openai_relay_user",
|
||||
},
|
||||
Audio: VolcengineTTSAudio{
|
||||
VoiceType: voiceType,
|
||||
Encoding: encoding,
|
||||
SpeedRatio: speedRatio,
|
||||
Rate: 24000,
|
||||
},
|
||||
Request: VolcengineTTSReqInfo{
|
||||
ReqID: generateRequestID(),
|
||||
Text: request.Input,
|
||||
Operation: "submit",
|
||||
Model: info.OriginModelName,
|
||||
},
|
||||
}
|
||||
|
||||
if len(request.Metadata) > 0 {
|
||||
if err = json.Unmarshal(request.Metadata, &volcRequest); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling metadata to volcengine request: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(contextKeyTTSRequest, volcRequest)
|
||||
|
||||
if volcRequest.Request.Operation == "submit" {
|
||||
info.IsStream = true
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(volcRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling volcengine request: %w", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(jsonData), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return request, nil
|
||||
case constant.RelayModeImagesEdits:
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
|
||||
writer.WriteField("model", request.Model)
|
||||
// 获取所有表单字段
|
||||
formData := c.Request.PostForm
|
||||
// 遍历表单字段并打印输出
|
||||
for key, values := range formData {
|
||||
if key == "model" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
writer.WriteField(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the multipart form to handle both single image and multiple images
|
||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory
|
||||
return nil, errors.New("failed to parse multipart form")
|
||||
}
|
||||
|
||||
if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
|
||||
// Check if "image" field exists in any form, including array notation
|
||||
var imageFiles []*multipart.FileHeader
|
||||
var exists bool
|
||||
|
||||
// First check for standard "image" field
|
||||
if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 {
|
||||
// If not found, check for "image[]" field
|
||||
if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 {
|
||||
// If still not found, iterate through all fields to find any that start with "image["
|
||||
foundArrayImages := false
|
||||
for fieldName, files := range c.Request.MultipartForm.File {
|
||||
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
|
||||
foundArrayImages = true
|
||||
for _, file := range files {
|
||||
imageFiles = append(imageFiles, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no image fields found at all
|
||||
if !foundArrayImages && (len(imageFiles) == 0) {
|
||||
return nil, errors.New("image is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process all image files
|
||||
for i, fileHeader := range imageFiles {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// If multiple images, use image[] as the field name
|
||||
fieldName := "image"
|
||||
if len(imageFiles) > 1 {
|
||||
fieldName = "image[]"
|
||||
}
|
||||
|
||||
// Determine MIME type based on file extension
|
||||
mimeType := detectImageMimeType(fileHeader.Filename)
|
||||
|
||||
// Create a form file with the appropriate content type
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
|
||||
h.Set("Content-Type", mimeType)
|
||||
|
||||
part, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mask file if present
|
||||
if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 {
|
||||
maskFile, err := maskFiles[0].Open()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open mask file")
|
||||
}
|
||||
defer maskFile.Close()
|
||||
|
||||
// Determine MIME type for mask file
|
||||
mimeType := detectImageMimeType(maskFiles[0].Filename)
|
||||
|
||||
// Create a form file with the appropriate content type
|
||||
h := make(textproto.MIMEHeader)
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
|
||||
h.Set("Content-Type", mimeType)
|
||||
|
||||
maskPart, err := writer.CreatePart(h)
|
||||
if err != nil {
|
||||
return nil, errors.New("create form file failed for mask")
|
||||
}
|
||||
|
||||
if _, err := io.Copy(maskPart, maskFile); err != nil {
|
||||
return nil, errors.New("copy mask file failed")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("no multipart form data found")
|
||||
}
|
||||
|
||||
// 关闭 multipart 编写器以设置分界线
|
||||
writer.Close()
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return bytes.NewReader(requestBody.Bytes()), nil
|
||||
// 根据官方文档,并没有发现豆包生图支持表单请求:https://www.volcengine.com/docs/82379/1824121
|
||||
//case constant.RelayModeImagesEdits:
|
||||
//
|
||||
// var requestBody bytes.Buffer
|
||||
// writer := multipart.NewWriter(&requestBody)
|
||||
//
|
||||
// writer.WriteField("model", request.Model)
|
||||
//
|
||||
// formData := c.Request.PostForm
|
||||
// for key, values := range formData {
|
||||
// if key == "model" {
|
||||
// continue
|
||||
// }
|
||||
// for _, value := range values {
|
||||
// writer.WriteField(key, value)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
||||
// return nil, errors.New("failed to parse multipart form")
|
||||
// }
|
||||
//
|
||||
// if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
|
||||
// var imageFiles []*multipart.FileHeader
|
||||
// var exists bool
|
||||
//
|
||||
// if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 {
|
||||
// if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 {
|
||||
// foundArrayImages := false
|
||||
// for fieldName, files := range c.Request.MultipartForm.File {
|
||||
// if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
|
||||
// foundArrayImages = true
|
||||
// for _, file := range files {
|
||||
// imageFiles = append(imageFiles, file)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if !foundArrayImages && (len(imageFiles) == 0) {
|
||||
// return nil, errors.New("image is required")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// for i, fileHeader := range imageFiles {
|
||||
// file, err := fileHeader.Open()
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
|
||||
// }
|
||||
// defer file.Close()
|
||||
//
|
||||
// fieldName := "image"
|
||||
// if len(imageFiles) > 1 {
|
||||
// fieldName = "image[]"
|
||||
// }
|
||||
//
|
||||
// mimeType := detectImageMimeType(fileHeader.Filename)
|
||||
//
|
||||
// h := make(textproto.MIMEHeader)
|
||||
// h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
|
||||
// h.Set("Content-Type", mimeType)
|
||||
//
|
||||
// part, err := writer.CreatePart(h)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
|
||||
// }
|
||||
//
|
||||
// if _, err := io.Copy(part, file); err != nil {
|
||||
// return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 {
|
||||
// maskFile, err := maskFiles[0].Open()
|
||||
// if err != nil {
|
||||
// return nil, errors.New("failed to open mask file")
|
||||
// }
|
||||
// defer maskFile.Close()
|
||||
//
|
||||
// mimeType := detectImageMimeType(maskFiles[0].Filename)
|
||||
//
|
||||
// h := make(textproto.MIMEHeader)
|
||||
// h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
|
||||
// h.Set("Content-Type", mimeType)
|
||||
//
|
||||
// maskPart, err := writer.CreatePart(h)
|
||||
// if err != nil {
|
||||
// return nil, errors.New("create form file failed for mask")
|
||||
// }
|
||||
//
|
||||
// if _, err := io.Copy(maskPart, maskFile); err != nil {
|
||||
// return nil, errors.New("copy mask file failed")
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// return nil, errors.New("no multipart form data found")
|
||||
// }
|
||||
//
|
||||
// writer.Close()
|
||||
// c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
// return bytes.NewReader(requestBody.Bytes()), nil
|
||||
|
||||
default:
|
||||
return request, nil
|
||||
}
|
||||
}
|
||||
|
||||
// detectImageMimeType determines the MIME type based on the file extension
|
||||
func detectImageMimeType(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
@@ -177,11 +223,9 @@ func detectImageMimeType(filename string) string {
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
// Try to detect from extension if possible
|
||||
if strings.HasPrefix(ext, ".jp") {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// Default to png as a fallback
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
@@ -190,7 +234,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
// 支持自定义域名,如果未设置则使用默认域名
|
||||
baseUrl := info.ChannelBaseUrl
|
||||
if baseUrl == "" {
|
||||
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
|
||||
@@ -198,6 +241,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
if baseUrl == DoubaoCodingPlan {
|
||||
return fmt.Sprintf("%s/v1/messages", DoubaoCodingPlanClaudeBaseURL), nil
|
||||
}
|
||||
if strings.HasPrefix(info.UpstreamModelName, "bot") {
|
||||
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
|
||||
}
|
||||
@@ -205,18 +251,27 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
if baseUrl == DoubaoCodingPlan {
|
||||
return fmt.Sprintf("%s/chat/completions", DoubaoCodingPlanOpenAIBaseURL), nil
|
||||
}
|
||||
if strings.HasPrefix(info.UpstreamModelName, "bot") {
|
||||
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
|
||||
case constant.RelayModeEmbeddings:
|
||||
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
//豆包的图生图也走generations接口: https://www.volcengine.com/docs/82379/1824121
|
||||
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
|
||||
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
|
||||
case constant.RelayModeImagesEdits:
|
||||
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
|
||||
//case constant.RelayModeImagesEdits:
|
||||
// return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
|
||||
case constant.RelayModeRerank:
|
||||
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
|
||||
return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil
|
||||
}
|
||||
return fmt.Sprintf("%s/v1/audio/speech", baseUrl), nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -225,6 +280,18 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
parts := strings.Split(info.ApiKey, "|")
|
||||
if len(parts) == 2 {
|
||||
req.Set("Authorization", "Bearer;"+parts[1])
|
||||
}
|
||||
req.Set("Content-Type", "application/json")
|
||||
return nil
|
||||
} else if info.RelayMode == constant.RelayModeImagesEdits {
|
||||
req.Set("Content-Type", gin.MIMEJSON)
|
||||
}
|
||||
|
||||
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
return nil
|
||||
}
|
||||
@@ -233,8 +300,10 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// 适配 方舟deepseek混合模型 的 thinking 后缀
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") {
|
||||
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
|
||||
strings.HasSuffix(info.UpstreamModelName, "-thinking") &&
|
||||
strings.HasPrefix(info.UpstreamModelName, "deepseek") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
|
||||
request.Model = info.UpstreamModelName
|
||||
request.THINKING = json.RawMessage(`{"type": "enabled"}`)
|
||||
@@ -251,15 +320,61 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
baseUrl := info.ChannelBaseUrl
|
||||
if baseUrl == "" {
|
||||
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
|
||||
}
|
||||
|
||||
if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
|
||||
if info.IsStream {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
|
||||
if info.IsStream {
|
||||
volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
|
||||
if !exists {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
errors.New("volcengine TTS request not found in context"),
|
||||
types.ErrorCodeBadRequestBody,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
volcRequest, ok := volcRequestInterface.(VolcengineTTSRequest)
|
||||
if !ok {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
errors.New("invalid volcengine TTS request type"),
|
||||
types.ErrorCodeBadRequestBody,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the WebSocket URL
|
||||
requestURL, urlErr := a.GetRequestURL(info)
|
||||
if urlErr != nil {
|
||||
return nil, types.NewErrorWithStatusCode(
|
||||
urlErr,
|
||||
types.ErrorCodeBadRequestBody,
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
}
|
||||
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)
|
||||
}
|
||||
return handleTTSResponse(c, resp, info, encoding)
|
||||
}
|
||||
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user