Compare commits

...

121 Commits

Author SHA1 Message Date
Calcium-Ion
f6e7a2344b Merge pull request #2340 from QuantumNous/revert-2305-pr/add-gemini-3-pro-image-preview-oai
Revert "OAI生图接口支持gemini 3 pro image preview"
2025-11-30 18:50:16 +08:00
Seefs
3257723a55 Revert "OAI生图接口支持gemini 3 pro image preview" 2025-11-30 18:49:18 +08:00
Calcium-Ion
b19b2d62df Merge pull request #2339 from QuantumNous/revert-2330-pr/fix-nano-banana-err
Revert "fix: nano-banana not compatible imageSize"
2025-11-30 18:48:09 +08:00
Calcium-Ion
f9c8624f2c Merge pull request #2338 from QuantumNous/revert-2321-pr/gemini-image-edit
Revert "Gemini Image系列支持图像编辑"
2025-11-30 18:48:01 +08:00
Calcium-Ion
6c8253156b Merge pull request #2337 from QuantumNous/revert-2315-pr/gemini-veo3.1-i2v
Revert "Gemini Veo3.1[AI Studio]增加图生视频支持"
2025-11-30 18:47:50 +08:00
Calcium-Ion
a66b314f5b Merge pull request #2336 from QuantumNous/revert-2309-pr/fix-gemini-ImageConfig
Revert "fix: gemini image correct generationConfig"
2025-11-30 18:47:39 +08:00
Seefs
e29ff0060d Revert "fix: nano-banana not compatible imageSize" 2025-11-30 18:46:10 +08:00
Seefs
d4a2c2ab54 Revert "Gemini Image系列支持图像编辑" 2025-11-30 18:45:54 +08:00
Seefs
ded463ee57 Revert "Gemini Veo3.1[AI Studio]增加图生视频支持" 2025-11-30 18:45:37 +08:00
Seefs
e337936227 Revert "fix: gemini image correct generationConfig" 2025-11-30 18:45:23 +08:00
Seefs
8d0827cb9e Merge pull request #2314 from seefs001/fix/i18n-missing
fix(i18n): fill missing translations in i18n.
2025-11-30 16:31:52 +08:00
Calcium-Ion
c07331ee21 Merge pull request #2304 from seefs001/fix/claude-missing-field
fix: claude request missing field
2025-11-30 16:22:35 +08:00
Calcium-Ion
287a59e2fd fix: edit vertex key type (#2311) 2025-11-30 16:21:49 +08:00
Seefs
451c594e34 Merge pull request #2334 from seefs001/feature/glm-coding
feat: glm coding plan && kimi coding plan
2025-11-30 16:21:12 +08:00
Calcium-Ion
46a18c4658 Merge pull request #2335 from seefs001/fix/nano-banana-pro-4k
fix: nano banana pro 4k(StreamScannerMaxBufferMB env)
2025-11-30 16:20:46 +08:00
Calcium-Ion
d5cb53154f Merge pull request #2312 from ImogeneOctaviap794/feat/enhance-playground-debugging
feat(playground): enhance SSE debugging and add image paste support with i18n
2025-11-30 16:20:39 +08:00
Seefs
2b54e5fc53 Merge pull request #2330 from feitianbubu/pr/fix-nano-banana-err
fix: nano-banana not compatible imageSize
2025-11-30 16:18:20 +08:00
Seefs
2520c8b25d fix: nano banana pro 4k(StreamScannerMaxBufferMB env) 2025-11-30 16:08:25 +08:00
Seefs
590745b846 Merge pull request #2329 from mfzzf/fix/aws-anthropic-http-err-code
fix(aws): extract HTTP status code from AWS SDK errors
2025-11-29 15:19:01 +08:00
feitianbubu
77eb536b69 fix: nano-banana not compatible imageSize 2025-11-29 00:58:25 +08:00
jason.mei
c6a8e4c252 fix(aws): simplify HTTP status code extraction from AWS errors 2025-11-28 18:03:53 +08:00
jason.mei
f2e51963dc fix(aws): extract HTTP status code from AWS SDK errors 2025-11-28 17:43:37 +08:00
IcedTangerine
fa72a27a59 Merge pull request #2324 from feitianbubu/pr/video-download-oai
feat: 视频下载和界面预览统一使用OAI标准接口
2025-11-28 17:03:39 +08:00
feitianbubu
2a77453e1a feat: all video preview use videos/:id/content 2025-11-28 13:11:31 +08:00
IcedTangerine
b47cf4efb3 Merge pull request #2321 from feitianbubu/pr/gemini-image-edit
Gemini Image系列支持图像编辑
2025-11-27 18:04:50 +08:00
IcedTangerine
420c6e58f2 Fix defer placement for image file closure 2025-11-27 18:01:34 +08:00
IcedTangerine
4d00dad002 Fix error message formatting in relay_utils.go 2025-11-27 17:59:38 +08:00
IcedTangerine
a0982996a4 Use defer to close image file after opening
Ensure image file is closed using defer after opening.
2025-11-27 17:56:59 +08:00
IcedTangerine
36cf515617 Merge pull request #2315 from feitianbubu/pr/gemini-veo3.1-i2v
Gemini Veo3.1[AI Studio]增加图生视频支持
2025-11-27 17:24:13 +08:00
feitianbubu
cb5a37abed feat: gemini image support edit 2025-11-27 16:04:04 +08:00
feitianbubu
f7d6c36032 feat: gemini video veo3.1 add task fail check 2025-11-26 21:56:14 +08:00
feitianbubu
4a367edfde feat: gemini video veo3.1 add i2v 2025-11-26 21:56:13 +08:00
ImogeneOctaviap794
9140dee70c feat(playground): enhance SSE debugging and add image paste support with i18n
- Add SSEViewer component for interactive SSE message inspection
  * Display SSE data stream with collapsible panels
  * Show parsed JSON with syntax highlighting
  * Display key information badges (content, tokens, finish reason)
  * Support copy individual or all SSE messages
  * Show error messages with detailed information

- Support Ctrl+V to paste images in chat input
  * Enable image paste in CustomInputRender component
  * Auto-detect and add pasted images to image list
  * Show toast notifications for paste results

- Add complete i18n support for 6 languages
  * Chinese (zh): Complete translations
  * English (en): Complete translations
  * Japanese (ja): Add 28 new translations
  * French (fr): Add 28 new translations
  * Russian (ru): Add 28 new translations
  * Vietnamese (vi): Add 32 new translations

- Update .gitignore to exclude data directory
2025-11-26 20:40:32 +08:00
Calcium-Ion
95a7749e1d Merge pull request #2309 from feitianbubu/pr/fix-gemini-ImageConfig
fix: gemini image correct generationConfig
2025-11-26 18:46:06 +08:00
Seefs
a25d00bace fix: edit vertex key type 2025-11-26 18:12:36 +08:00
feitianbubu
ab3cda3202 fix: gemini image correct generationConfig 2025-11-26 15:54:11 +08:00
IcedTangerine
5ac1d02200 Merge pull request #2305 from feitianbubu/pr/add-gemini-3-pro-image-preview-oai
OAI生图接口支持gemini 3 pro image preview
2025-11-26 13:35:17 +08:00
feitianbubu
d859872e0d feat: gemini-3-pro-image-preview add extra param 2025-11-26 12:03:24 +08:00
feitianbubu
bff04514a8 feat: support gemini-3-pro-image-preview via images/generations 2025-11-26 12:03:24 +08:00
Seefs
dab5fad61e fix: claude request missing field 2025-11-26 02:06:25 +08:00
Seefs
a6a20a2069 Merge pull request #2296 from seefs001/fix/adapter-missing
fix: volcengine claude DoResponse
2025-11-25 16:45:14 +08:00
Calcium-Ion
4866b3db13 Merge pull request #2295 from seefs001/fix/adapter-missing
fix: volcengine claude DoResponse
2025-11-25 15:54:39 +08:00
Seefs
5060904331 fix: volcengine claude DoResponse 2025-11-25 15:45:31 +08:00
Calcium-Ion
393c2b620c Merge pull request #2294 from seefs001/fix/adapter-missing
fix: volcengine && baidu claude adapter
2025-11-25 15:31:26 +08:00
Seefs
e5e3e0f201 fix: volcengine && baidu claude adapter 2025-11-25 15:06:03 +08:00
Seefs
b3d5fbd9f2 Merge pull request #2282 from amikebzek/claude/analyze-gemini-integration-011nJGemhrPUdqwg3qDvmqVB
feat: enable thoughtSignature for non-function-call messages
2025-11-25 14:50:55 +08:00
Seefs
31a652f8e2 Merge pull request #2293 from prnake/claude-opus-4-5
feat: add claude-opus-4-5-20251101
2025-11-25 14:44:57 +08:00
Papersnake
79682dc542 feat: add claude-opus-4-5-20251101 2025-11-25 10:53:01 +08:00
Papersnake
5931d333cb feat: add claude-opus-4-5-20251101 ratio 2025-11-25 10:49:34 +08:00
Seefs
2f80e3fba1 Merge pull request #2261 from wzxjohn/hotfix/analytic
fix: root page does not have analytic code
2025-11-24 14:06:02 +08:00
Seefs
bd9e23ce4e Merge pull request #2264 from binorxin/main
fix: cast size to int64 before comparing with MaxUint32
2025-11-24 14:05:14 +08:00
Claude
25aed08361 feat: enable thoughtSignature for non-function-call messages
Previously thoughtSignature was only attached to messages with function
calls. This change extends the feature to also attach thoughtSignature
to the first text part of assistant/model messages when no tool_calls
are present, ensuring compatibility with Gemini thinking models in
regular conversation scenarios.
2025-11-24 00:31:20 +00:00
Calcium-Ion
3f19f18dc9 Merge pull request #2278 from seefs001/fix/release-version
fix: release workflow show version
2025-11-23 23:51:32 +08:00
Calcium-Ion
a465597e78 Merge pull request #2277 from seefs001/feature/model_list_fetch
feat: 二次确认添加重定向前模型 && 重定向后模式视为已有模型
2025-11-23 23:51:11 +08:00
Calcium-Ion
dbfcb441f7 Merge pull request #2276 from seefs001/feature/internal_params
feat: embedding param override && internal params
2025-11-23 23:51:00 +08:00
Calcium-Ion
3fb2ba318d Merge pull request #2274 from seefs001/feature/thinking_level
feat: gemini thinking_level && snake params
2025-11-23 23:50:50 +08:00
CaIon
8f039b3a53 feat: Set ContextKeyLocalCountTokens in NativeGeminiEmbeddingHandler for token tracking 2025-11-23 23:50:04 +08:00
CaIon
c939686509 refactor: Deprecate HARM_CATEGORY_CIVIC_INTEGRITY in safety settings 2025-11-23 23:45:48 +08:00
Seefs
07aff1fe02 Merge pull request #1706 from StageDog/feat/discord_oauth
feat: 关联 discord 账号
2025-11-23 18:54:55 +08:00
StageDog
5f27edcd19 fix: IsDiscordIdAlreadyTaken 应该检查软删除记录 2025-11-23 00:07:34 +08:00
Seefs
f47d473e63 fix: release workflow show version 2025-11-22 20:06:13 +08:00
Seefs
7a2bd38700 feat: 重定向后的模型视为已有的模型,附带特殊提示 2025-11-22 19:34:36 +08:00
Seefs
f8c40ecca6 feat: 二次确认添加重定向前模型 2025-11-22 19:23:27 +08:00
StageDog
2bc991685f feat: 针对 discord 登录配置使用新版设置方案 2025-11-22 19:06:53 +08:00
StageDog
87811a0493 feat: 关联 discord 账号 2025-11-22 18:38:24 +08:00
Seefs
0885597427 feat: embedding param override && internal params 2025-11-22 18:27:17 +08:00
CaIon
0952973887 feat: Add CountToken configuration and update token counting logic 2025-11-22 17:15:34 +08:00
Seefs
6b30f042fa feat: gemini thinking_level && snake params 2025-11-22 16:30:46 +08:00
CaIon
efb8f1f5b8 fix: Update GET_MEDIA_TOKEN_NOT_STREAM default value to false 2025-11-22 16:23:37 +08:00
Seefs
de3cf9893d Merge pull request #2268 from chokiproai/main
feat: Add Vietnamese language support
2025-11-22 00:47:32 +08:00
Seefs
fe02e9a066 Merge pull request #2224 from jarvis-u/main
fix: 错误解析responses api中的input字段
2025-11-22 00:31:24 +08:00
CaIon
84745d5ca4 feat: Add ContextKeyLocalCountTokens and update ResponseText2Usage to use context in multiple channels 2025-11-21 18:17:01 +08:00
Chokiproai
cdb1c06ad2 add Vietnamese language support 2025-11-21 10:40:14 +07:00
borx
182f3a9b4d fix: cast size to int64 before comparing with MaxUint32 2025-11-20 23:57:30 +08:00
Calcium-Ion
ef0647285c Merge pull request #2260 from seefs001/fix/multi-key-fetch-models
fix: When retrieving the model list with multiple keys, select the first enabled one.
2025-11-20 18:16:05 +08:00
Seefs
33b1fad5f8 fix: When retrieving the model list with multiple keys, select the first enabled one. 2025-11-20 18:02:17 +08:00
Calcium-Ion
b899122dfe Merge pull request #2256 from seefs001/feature/gemini-3-openai
feat: Fill thoughtSignature only for Gemini/Vertex channels using OpenAI format
2025-11-20 16:05:41 +08:00
Seefs
50c04a62f9 feat: Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format 2025-11-20 15:54:33 +08:00
Calcium-Ion
554b68484c Merge pull request #2250 from seefs001/fix/claude-cache-price-render
fix: claude cache price render
2025-11-20 15:13:16 +08:00
Calcium-Ion
6a1c046714 Merge pull request #2252 from QuantumNous/dependabot/go_modules/golang.org/x/crypto-0.45.0
chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.45.0
2025-11-20 15:13:00 +08:00
dependabot[bot]
0b37bdddc6 chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:46:07 +00:00
Seefs
563a426c00 fix: claude cache price render 2025-11-20 00:56:09 +08:00
Seefs
f6a5d9ef7e Merge pull request #2247 from feitianbubu/pr/channel-omit-key
feat: channel by tag omit key
2025-11-19 19:38:59 +08:00
feitianbubu
a7d2450704 feat: channel by tag omit key 2025-11-19 19:25:27 +08:00
Calcium-Ion
75fced3d9c Merge pull request #2243 from seefs001/feature/gemini-3
feat: gemini-3-pro
2025-11-19 14:52:00 +08:00
Calcium-Ion
5a1bbd1059 Merge pull request #2231 from QuantumNous/dependabot/npm_and_yarn/electron/js-yaml-4.1.1
chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in /electron
2025-11-19 14:51:26 +08:00
Calcium-Ion
c133678cb1 fix: optimized the GitHub login copy and timeout. (#2244) 2025-11-19 14:50:56 +08:00
Seefs
1fc3c4b09d fix: optimized the GitHub login copy and timeout. 2025-11-19 14:34:30 +08:00
Seefs
77c4c3e804 feat: MediaResolution && VideoMetadata 2025-11-19 13:42:32 +08:00
Seefs
bc1f747418 feat: gemini-3-pro 2025-11-19 01:46:51 +08:00
CaIon
62edac7c7f fix: aws 2025-11-18 16:56:46 +08:00
Seefs
ff839df279 Merge pull request #2239 from QAbot-zh/modelCategories-update
update model categories' match rules
2025-11-17 16:08:04 +08:00
undefinedcodezhong
8b8511b19e update model categories' match rules 2025-11-17 14:54:12 +08:00
Seefs
7598753f4e Merge pull request #2238 from seefs001/feature/doubao-coding-plan
feat: support doubao coding plan
2025-11-16 23:49:35 +08:00
Calcium-Ion
68777bf05f Merge pull request #2237 from seefs001/feature/linux-do-settings
feat: support configuring the linuxdo endpoint via environment variables
2025-11-16 15:43:47 +08:00
Seefs
b6217b22b0 feat: linuxdo oauth endpoint -> environment 2025-11-16 14:50:59 +08:00
CaIon
196fa135fd feat(adaptor): Add support for Claude-specific headers in SetupRequestHeader 2025-11-16 14:28:41 +08:00
Calcium-Ion
ff3225ab44 Merge pull request #2236 from seefs001/feature/vertex-k2
feat: support vertex open source models
2025-11-16 14:24:15 +08:00
Seefs
ab36de3725 feature: support vertex open source models 2025-11-16 14:23:11 +08:00
Calcium-Ion
2b4617dc1b Merge pull request #2235 from seefs001/fix/boundary-parser-error
fix: boundary parser error (error parsing multipart NextPart: bufio: buffer full)
2025-11-16 14:12:46 +08:00
Seefs
e169818404 fix: boundary parser error (error parsing multipart form: multipart: NextPart: bufio: buffer full) 2025-11-16 14:09:10 +08:00
dependabot[bot]
c1a696e6f0 chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in /electron
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-15 20:14:28 +00:00
Seefs
e07347ac53 feat: support gpt-5.1 prompt_cache_retention (#2228) 2025-11-15 13:32:24 +08:00
Seefs
fd38abd562 Merge pull request #2207 from QAbot-zh/reasoning
support reasoning field for playground
2025-11-15 13:26:57 +08:00
IcedTangerine
293c0277a8 Merge pull request #2227 from feitianbubu/pr/add-wan2.5-i2i-preview
增加wan2.5-i2i-preview图生图支持
2025-11-15 12:43:09 +08:00
feitianbubu
344a799fcf feat: add wan2.5-i2i-preview support 2025-11-14 20:30:18 +08:00
Seefs
35192e5675 Merge pull request #2226 from QuantumNous/omit-anthropic_beta-empty
fix(relay/channel/aws): 修复AnthropicBeta字段的omitempty处理
2025-11-14 16:55:20 +08:00
creamlike1024
9e80e4e7e5 fix(relay/channel/aws): 修复AnthropicBeta字段的omitempty处理 2025-11-14 15:54:12 +08:00
IcedTangerine
e7bef097dd Merge pull request #2225 from feitianbubu/pr/add-hailuo-video
新增MiniMax海螺视频模型支持
2025-11-14 14:48:59 +08:00
CaIon
41b2341b0b fix(adaptor): Add '-none' suffix to effortSuffixes for model parsing 2025-11-14 14:04:34 +08:00
CaIon
e1a52f1d5a feat(aws): Add support for anthropic-beta header in AwsClaudeRequest 2025-11-14 12:01:20 +08:00
feitianbubu
d8dc8029c0 feat: add hailuo i2v fl2v r2v 2025-11-14 11:55:43 +08:00
feitianbubu
87bc4ba419 feat: get hailuo video url 2025-11-14 11:55:43 +08:00
feitianbubu
850a553958 feat: add MiniMax Hailuo video 2025-11-14 11:55:43 +08:00
wujiacheng
d9b5748f80 fix: 错误解析responses api中的input字段 2025-11-14 09:58:39 +08:00
Calcium-Ion
974df5e7b9 Merge pull request #2222 from xyfacai/main
fix: 未设置价格模型不会被拉取,除非设置自用模式
2025-11-13 19:00:21 +08:00
Xyfacai
06cd774c10 fix: 未设置价格模型不会被拉取,除非设置自用模式 2025-11-13 18:44:18 +08:00
CaIon
4419be9c09 fix(claude): Prevent duplicate header values in WriteHeaders method 2025-11-13 16:49:40 +08:00
Q.A.zh
fb3b27a626 support reasoning field 2025-11-11 13:00:20 +00:00
wzxjohn
2a62aea46c fix: typo 2025-10-30 14:21:46 +08:00
wzxjohn
4a0c119140 fix(web): index page does not have analytic 2025-10-30 12:17:51 +08:00
122 changed files with 6158 additions and 556 deletions

View File

@@ -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

View File

@@ -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 }}

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@ new-api
tiktoken_cache
.eslintcache
.gocache
.cache
web/bun.lock
electron/node_modules
electron/dist
data/

View File

@@ -193,6 +193,7 @@ docker run --name new-api -d --restart always \
### 🔐 Authorization and Security
- 😈 Discord authorization login
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
@@ -302,6 +303,7 @@ docker run --name new-api -d --restart always \
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |

View File

@@ -299,6 +299,7 @@ docker run --name new-api -d --restart always \
| `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` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` |
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
@@ -438,4 +439,4 @@ Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile
<sub>Construit avec ❤️ par QuantumNous</sub>
</div>
</div>

View File

@@ -308,6 +308,7 @@ docker run --name new-api -d --restart always \
| `SQL_DSN** | データベース接続文字列 | - |
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限MB。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` |
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |

View File

@@ -193,6 +193,7 @@ docker run --name new-api -d --restart always \
### 🔐 授权与安全
- 😈 Discord 授权登录
- 🤖 LinuxDO 授权登录
- 📱 Telegram 授权登录
- 🔑 OIDC 统一认证
@@ -296,15 +297,16 @@ docker run --name new-api -d --restart always \
<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` |
| 变量名 | 说明 | 默认值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 会话密钥(多机部署必须) | - |
| `CRYPTO_SECRET` | 加密密钥Redis 必须) | - |
| `SQL_DSN` | 数据库连接字符串 | - |
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲MB图像生成等超大 `data:` 片段(如 4K 图片 base64需适当调大 | `64` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables)

View File

@@ -4,6 +4,7 @@ import (
"embed"
"io/fs"
"net/http"
"os"
"github.com/gin-contrib/static"
)
@@ -14,7 +15,7 @@ type embedFileSystem struct {
http.FileSystem
}
func (e embedFileSystem) Exists(prefix string, path string) bool {
func (e *embedFileSystem) Exists(prefix string, path string) bool {
_, err := e.Open(path)
if err != nil {
return false
@@ -22,12 +23,21 @@ func (e embedFileSystem) Exists(prefix string, path string) bool {
return true
}
func (e *embedFileSystem) Open(name string) (http.File, error) {
if name == "/" {
// This will make sure the index page goes to NoRouter handler,
// which will use the replaced index bytes with analytic codes.
return nil, os.ErrNotExist
}
return e.FileSystem.Open(name)
}
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
efs, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
}
return embedFileSystem{
return &embedFileSystem{
FileSystem: http.FS(efs),
}
}

View File

@@ -2,7 +2,9 @@ package common
import (
"bytes"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
@@ -128,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
}
@@ -177,17 +179,16 @@ func parseFormData(data []byte, v any) error {
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
contentType := c.Request.Header.Get("Content-Type")
boundary := ""
if idx := strings.Index(contentType, "boundary="); idx != -1 {
boundary = contentType[idx+9:]
}
if boundary == "" {
return Unmarshal(data, v) // Fallback to JSON
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(32 << 20) // 32 MB max memory
form, err := reader.ReadForm(multipartMemoryLimit())
if err != nil {
return err
}
@@ -203,3 +204,31 @@ func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
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
}

View File

@@ -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)
@@ -109,10 +114,12 @@ func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
// 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)

View File

@@ -180,3 +180,27 @@ func GetChannelTypeName(channelType int) string {
}
return "Unknown"
}
type ChannelSpecialBase struct {
ClaudeBaseURL string
OpenAIBaseURL string
}
var ChannelSpecialBases = map[string]ChannelSpecialBase{
"glm-coding-plan": {
ClaudeBaseURL: "https://open.bigmodel.cn/api/anthropic",
OpenAIBaseURL: "https://open.bigmodel.cn/api/coding/paas/v4",
},
"glm-coding-plan-international": {
ClaudeBaseURL: "https://api.z.ai/api/anthropic",
OpenAIBaseURL: "https://api.z.ai/api/coding/paas/v4",
},
"kimi-coding-plan": {
ClaudeBaseURL: "https://api.kimi.com/coding",
OpenAIBaseURL: "https://api.kimi.com/coding/v1",
},
"doubao-coding-plan": {
ClaudeBaseURL: "https://ark.cn-beijing.volces.com/api/coding",
OpenAIBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
}

View File

@@ -46,5 +46,7 @@ const (
ContextKeyUsingGroup ContextKey = "group"
ContextKeyUserName ContextKey = "username"
ContextKeyLocalCountTokens ContextKey = "local_count_tokens"
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
)

View File

@@ -3,7 +3,9 @@ package constant
var StreamingTimeout int
var DifyDebug bool
var MaxFileDownloadMB int
var StreamScannerMaxBufferMB int
var ForceStreamOption bool
var CountToken bool
var GetMediaToken bool
var GetMediaTokenNotStream bool
var UpdateTask bool

View File

@@ -91,7 +91,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
}
@@ -191,14 +191,40 @@ func FetchUpstreamModels(c *gin.Context) {
case constant.ChannelTypeAli:
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
case constant.ChannelTypeZhipu_v4:
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
}
case constant.ChannelTypeVolcEngine:
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/v1/models", baseURL)
}
case constant.ChannelTypeMoonshot:
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
} 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 +297,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...)
}
@@ -1021,7 +1047,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
View 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",
})
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View File

@@ -16,6 +16,8 @@ import (
"github.com/QuantumNous/new-api/relay/channel/moonshot"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"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)
@@ -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))

View File

@@ -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{

View File

@@ -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 {

View File

@@ -453,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,

View File

@@ -117,13 +117,12 @@ func VideoProxy(c *gin.Context) {
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
case constant.ChannelTypeOpenAI, constant.ChannelTypeSora:
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
req.Header.Set("Authorization", "Bearer "+channel.Key)
default:
// Video URL is directly in task.FailReason
videoURL = task.FailReason
}
req.URL, err = url.Parse(videoURL)

View File

@@ -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 | 公开 | 微信扫码登录跳转 |

View File

@@ -203,6 +203,9 @@ type ClaudeRequest struct {
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
OutputConfig json.RawMessage `json:"output_config,omitempty"`
OutputFormat json.RawMessage `json:"output_format,omitempty"`
Container json.RawMessage `json:"container,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`

View File

@@ -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"`

View File

@@ -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
@@ -798,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 {
@@ -895,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"`
@@ -913,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 {
@@ -923,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
}

View File

@@ -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": {

10
go.mod
View File

@@ -43,10 +43,10 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.42.0
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
@@ -111,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

20
go.sum
View File

@@ -281,18 +281,18 @@ 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=
@@ -304,15 +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=

View File

@@ -272,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
}
@@ -728,7 +732,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
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)

View File

@@ -429,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
}

View File

@@ -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
}

View File

@@ -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
@@ -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:

View File

@@ -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"`

View File

@@ -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 {
@@ -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 {

View 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")
}

View File

@@ -18,6 +18,7 @@ var awsModelIDMap = map[string]string{
"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",
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -76,6 +77,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-opus-4-5-20251101-v1:0": {
"us": true,
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,

View File

@@ -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
}

View File

@@ -25,6 +25,17 @@ import (
"github.com/aws/smithy-go/auth/bearer"
)
// getAwsErrorStatusCode extracts HTTP status code from AWS SDK error
func getAwsErrorStatusCode(err error) int {
// Check for HTTP response error which contains status code
var httpErr interface{ HTTPStatusCode() int }
if errors.As(err, &httpErr) {
return httpErr.HTTPStatusCode()
}
// Default to 500 if we can't determine the status code
return http.StatusInternalServerError
}
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
var (
httpClient *http.Client
@@ -73,7 +84,6 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
}
a.AwsClient = awsCli
println(info.UpstreamModelName)
// 获取对应的AWS模型ID
awsModelId := getAwsModelID(info.UpstreamModelName)
@@ -83,6 +93,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)
@@ -104,7 +118,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)
}
@@ -170,7 +184,8 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types
awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput))
if err != nil {
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
statusCode := getAwsErrorStatusCode(err)
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, statusCode), nil
}
claudeInfo := &claude.ClaudeResponseInfo{
@@ -196,7 +211,8 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types
func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
awsResp, err := a.AwsClient.InvokeModelWithResponseStream(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput))
if err != nil {
return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
statusCode := getAwsErrorStatusCode(err)
return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, statusCode), nil
}
stream := awsResp.GetStream()
defer stream.Close()
@@ -235,7 +251,8 @@ func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor)
awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput))
if err != nil {
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
statusCode := getAwsErrorStatusCode(err)
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, statusCode), nil
}
// 解析Nova响应

View File

@@ -21,6 +21,8 @@ var ModelList = []string{
"claude-opus-4-1-20250805-thinking",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-5-20250929-thinking",
"claude-opus-4-5-20251101",
"claude-opus-4-5-20251101-thinking",
}
var ChannelName = "claude"

View File

@@ -673,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 {
//上游出错
@@ -682,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)
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -177,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
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}
@@ -472,6 +484,17 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
}
}
// 如果需要附加签名但还没有附加(没有 tool_calls 或 tool_calls 为空),
// 则在第一个文本 part 上附加 thoughtSignature
if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
for i := range parts {
if parts[i].Text != "" {
parts[i].ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
break
}
}
}
content.Parts = parts
// there's no assistant role in gemini and API shall vomit if Role is not user or model
@@ -496,6 +519,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 +965,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 +978,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 +990,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 +1004,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 +1058,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 +1068,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 +1085,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
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
@@ -44,6 +45,16 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := info.ChannelBaseUrl
if specialPlan, ok := channelconstant.ChannelSpecialBases[baseURL]; ok {
if info.RelayFormat == types.RelayFormatClaude {
return fmt.Sprintf("%s/v1/messages", specialPlan.ClaudeBaseURL), nil
}
if info.RelayFormat == types.RelayFormatOpenAI {
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
}
}
switch info.RelayFormat {
case types.RelayFormatClaude:
return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil

View File

@@ -42,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, "-")

View File

@@ -183,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
}

View File

@@ -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)
}

View 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
}

View 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
)

View 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,
}
}

View File

@@ -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) {

View File

@@ -39,6 +39,7 @@ var claudeModelMap = map[string]string{
"claude-opus-4-20250514": "claude-opus-4@20250514",
"claude-opus-4-1-20250805": "claude-opus-4-1@20250805",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929",
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
}
const anthropicVersion = "vertex-2023-10-16"
@@ -76,7 +77,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
@@ -220,6 +223,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
}
@@ -291,7 +297,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
}

View File

@@ -13,6 +13,7 @@ import (
channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
@@ -36,6 +37,10 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
if _, ok := channelconstant.ChannelSpecialBases[info.ChannelBaseUrl]; ok {
adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
adaptor := openai.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
@@ -235,9 +240,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if baseUrl == "" {
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
}
specialPlan, hasSpecialPlan := channelconstant.ChannelSpecialBases[baseUrl]
switch info.RelayFormat {
case types.RelayFormatClaude:
if hasSpecialPlan && specialPlan.ClaudeBaseURL != "" {
return fmt.Sprintf("%s/v1/messages", specialPlan.ClaudeBaseURL), nil
}
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
@@ -245,6 +254,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
default:
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
}
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
@@ -331,6 +343,15 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayFormat == types.RelayFormatClaude {
if _, ok := channelconstant.ChannelSpecialBases[info.ChannelBaseUrl]; ok {
if info.IsStream {
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
}
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
}
if info.RelayMode == constant.RelayModeAudioSpeech {
encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
if info.IsStream {

View File

@@ -385,7 +385,7 @@ func (m *Message) writeSessionID(buf *bytes.Buffer) error {
}
size := len(m.SessionID)
if size > math.MaxUint32 {
if int64(size) > math.MaxUint32 {
return fmt.Errorf("session ID size (%d) exceeds max(uint32)", size)
}
@@ -407,7 +407,7 @@ func (m *Message) writeErrorCode(buf *bytes.Buffer) error {
func (m *Message) writePayload(buf *bytes.Buffer) error {
size := len(m.Payload)
if size > math.MaxUint32 {
if int64(size) > math.MaxUint32 {
return fmt.Errorf("payload size (%d) exceeds max(uint32)", size)
}

View File

@@ -70,7 +70,7 @@ func xAIStreamHandler(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
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
@@ -43,15 +44,30 @@ 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.ChannelTypeZhipu_v4]
}
specialPlan, hasSpecialPlan := channelconstant.ChannelSpecialBases[baseURL]
switch info.RelayFormat {
case types.RelayFormatClaude:
return fmt.Sprintf("%s/api/anthropic/v1/messages", info.ChannelBaseUrl), nil
if hasSpecialPlan && specialPlan.ClaudeBaseURL != "" {
return fmt.Sprintf("%s/v1/messages", specialPlan.ClaudeBaseURL), nil
}
return fmt.Sprintf("%s/api/anthropic/v1/messages", baseURL), nil
default:
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/paas/v4/embeddings", info.ChannelBaseUrl), nil
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/embeddings", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
default:
return fmt.Sprintf("%s/api/paas/v4/chat/completions", info.ChannelBaseUrl), nil
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/chat/completions", baseURL), nil
}
}
}

View File

@@ -1,7 +1,7 @@
package zhipu_4v
var ModelList = []string{
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus",
"glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", "glm-4.6",
}
var ChannelName = "zhipu_4v"

View File

@@ -123,7 +123,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -1,12 +1,12 @@
package common
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -30,7 +30,7 @@ type ParamOperation struct {
Logic string `json:"logic,omitempty"` // AND, OR (默认OR)
}
func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) {
func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, conditionContext map[string]interface{}) ([]byte, error) {
if len(paramOverride) == 0 {
return jsonData, nil
}
@@ -38,7 +38,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}) (
// 尝试断言为操作格式
if operations, ok := tryParseOperations(paramOverride); ok {
// 使用新方法
result, err := applyOperations(string(jsonData), operations)
result, err := applyOperations(string(jsonData), operations, conditionContext)
return []byte(result), err
}
@@ -123,13 +123,13 @@ func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation,
return nil, false
}
func checkConditions(jsonStr string, conditions []ConditionOperation, logic string) (bool, error) {
func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) {
if len(conditions) == 0 {
return true, nil // 没有条件,直接通过
}
results := make([]bool, len(conditions))
for i, condition := range conditions {
result, err := checkSingleCondition(jsonStr, condition)
result, err := checkSingleCondition(jsonStr, contextJSON, condition)
if err != nil {
return false, err
}
@@ -153,10 +153,13 @@ func checkConditions(jsonStr string, conditions []ConditionOperation, logic stri
}
}
func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) {
func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperation) (bool, error) {
// 处理负数索引
path := processNegativeIndex(jsonStr, condition.Path)
value := gjson.Get(jsonStr, path)
if !value.Exists() && contextJSON != "" {
value = gjson.Get(contextJSON, condition.Path)
}
if !value.Exists() {
if condition.PassMissingKey {
return true, nil
@@ -165,7 +168,7 @@ func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, e
}
// 利用gjson的类型解析
targetBytes, err := json.Marshal(condition.Value)
targetBytes, err := common.Marshal(condition.Value)
if err != nil {
return false, fmt.Errorf("failed to marshal condition value: %v", err)
}
@@ -292,7 +295,7 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool,
// applyOperationsLegacy 原参数覆盖方法
func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) {
reqMap := make(map[string]interface{})
err := json.Unmarshal(jsonData, &reqMap)
err := common.Unmarshal(jsonData, &reqMap)
if err != nil {
return nil, err
}
@@ -301,14 +304,23 @@ func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}
reqMap[key] = value
}
return json.Marshal(reqMap)
return common.Marshal(reqMap)
}
func applyOperations(jsonStr string, operations []ParamOperation) (string, error) {
func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) {
var contextJSON string
if conditionContext != nil && len(conditionContext) > 0 {
ctxBytes, err := common.Marshal(conditionContext)
if err != nil {
return "", fmt.Errorf("failed to marshal condition context: %v", err)
}
contextJSON = string(ctxBytes)
}
result := jsonStr
for _, op := range operations {
// 检查条件是否满足
ok, err := checkConditions(result, op.Conditions, op.Logic)
ok, err := checkConditions(result, contextJSON, op.Conditions, op.Logic)
if err != nil {
return "", err
}
@@ -414,7 +426,7 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
var currentMap, newMap map[string]interface{}
// 解析当前值
if err := json.Unmarshal([]byte(current.Raw), &currentMap); err != nil {
if err := common.Unmarshal([]byte(current.Raw), &currentMap); err != nil {
return "", err
}
// 解析新值
@@ -422,8 +434,8 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
case map[string]interface{}:
newMap = v
default:
jsonBytes, _ := json.Marshal(v)
if err := json.Unmarshal(jsonBytes, &newMap); err != nil {
jsonBytes, _ := common.Marshal(v)
if err := common.Unmarshal(jsonBytes, &newMap); err != nil {
return "", err
}
}
@@ -439,3 +451,31 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
}
return sjson.Set(jsonStr, path, result)
}
// BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。
// 目前内置以下字段:
// - model优先使用上游模型名UpstreamModelName若不存在则回落到原始模型名OriginModelName
// - upstream_model始终为通道映射后的上游模型名。
// - original_model请求最初指定的模型名。
func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} {
if info == nil || info.ChannelMeta == nil {
return nil
}
ctx := make(map[string]interface{})
if info.UpstreamModelName != "" {
ctx["model"] = info.UpstreamModelName
ctx["upstream_model"] = info.UpstreamModelName
}
if info.OriginModelName != "" {
ctx["original_model"] = info.OriginModelName
if _, exists := ctx["model"]; !exists {
ctx["model"] = info.OriginModelName
}
}
if len(ctx) == 0 {
return nil
}
return ctx
}

View File

@@ -498,11 +498,11 @@ type TaskSubmitReq struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (t TaskSubmitReq) GetPrompt() string {
func (t *TaskSubmitReq) GetPrompt() string {
return t.Prompt
}
func (t TaskSubmitReq) HasImage() bool {
func (t *TaskSubmitReq) HasImage() bool {
return len(t.Images) > 0
}
@@ -537,6 +537,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return nil
}
func (t *TaskSubmitReq) UnmarshalMetadata(v any) error {
metadata := t.Metadata
if metadata != nil {
metadataBytes, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("marshal metadata failed: %w", err)
}
err = json.Unmarshal(metadataBytes, v)
if err != nil {
return fmt.Errorf("unmarshal metadata to target failed: %w", err)
}
}
return nil
}
type TaskInfo struct {
Code int `json:"code"`

View File

@@ -144,7 +144,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -49,6 +49,14 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
}
logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData)))
requestBody := bytes.NewBuffer(jsonData)
statusCodeMappingStr := c.GetString("status_code_mapping")

View File

@@ -156,7 +156,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -22,11 +22,18 @@ import (
)
const (
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
DefaultPingInterval = 10 * time.Second
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
DefaultMaxScannerBufferSize = 64 << 20 // 64MB (64*1024*1024) default SSE buffer size
DefaultPingInterval = 10 * time.Second
)
func getScannerBufferSize() int {
if constant.StreamScannerMaxBufferMB > 0 {
return constant.StreamScannerMaxBufferMB << 20
}
return DefaultMaxScannerBufferSize
}
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string) bool) {
if resp == nil || dataHandler == nil {
@@ -95,7 +102,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
close(stopChan)
}()
scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize)
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
scanner.Split(bufio.ScanLines)
SetEventStreamHeaders(c)

View File

@@ -69,7 +69,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -32,6 +32,7 @@ import (
taskali "github.com/QuantumNous/new-api/relay/channel/task/ali"
taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao"
taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini"
"github.com/QuantumNous/new-api/relay/channel/task/hailuo"
taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng"
"github.com/QuantumNous/new-api/relay/channel/task/kling"
tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora"
@@ -153,6 +154,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &tasksora.TaskAdaptor{}
case constant.ChannelTypeGemini:
return &taskGemini.TaskAdaptor{}
case constant.ChannelTypeMiniMax:
return &hailuo.TaskAdaptor{}
}
}
return nil

View File

@@ -60,7 +60,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -66,7 +66,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}

View File

@@ -30,6 +30,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
apiRouter.GET("/oauth/discord", middleware.CriticalRateLimit(), controller.DiscordOAuth)
apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth)
apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth)
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)

View File

@@ -9,9 +9,9 @@ import (
func SetVideoRouter(router *gin.Engine) {
videoV1Router := router.Group("/v1")
videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy)
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy)
videoV1Router.POST("/video/generations", controller.RelayTask)
videoV1Router.GET("/video/generations/:task_id", controller.RelayTask)
}

View File

@@ -16,6 +16,7 @@ import (
"golang.org/x/image/webp"
)
// return image.Config, format, clean base64 string, error
func DecodeBase64ImageData(base64String string) (image.Config, string, string, error) {
// 去除base64数据的URL前缀如果有
if idx := strings.Index(base64String, ","); idx != -1 {

View File

@@ -62,6 +62,12 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
}
isLocalCountTokens := common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens)
if isLocalCountTokens {
adminInfo["local_count_tokens"] = isLocalCountTokens
}
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
return other

View File

@@ -143,6 +143,12 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
if fileMeta.Detail == "low" && !isPatchBased {
return baseTokens, nil
}
// Whether to count image tokens at all
if !constant.GetMediaToken {
return 3 * baseTokens, nil
}
if !constant.GetMediaTokenNotStream && !stream {
return 3 * baseTokens, nil
}
@@ -150,10 +156,6 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
if fileMeta.Detail == "auto" || fileMeta.Detail == "" {
fileMeta.Detail = "high"
}
// Whether to count image tokens at all
if !constant.GetMediaToken {
return 3 * baseTokens, nil
}
// Decode image to get dimensions
var config image.Config
@@ -256,16 +258,15 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
}
func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
// 是否统计token
if !constant.CountToken {
return 0, nil
}
if meta == nil {
return 0, errors.New("token count meta is nil")
}
if !constant.GetMediaToken {
return 0, nil
}
if !constant.GetMediaTokenNotStream && !info.IsStream {
return 0, nil
}
if info.RelayFormat == types.RelayFormatOpenAIRealtime {
return 0, nil
}
@@ -316,9 +317,19 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
shouldFetchFiles = false
}
if shouldFetchFiles {
for _, file := range meta.Files {
if strings.HasPrefix(file.OriginData, "http") {
// 是否本地计算媒体token数量
if !constant.GetMediaToken {
shouldFetchFiles = false
}
// 是否在非流模式下本地计算媒体token数量
if !constant.GetMediaTokenNotStream && !info.IsStream {
shouldFetchFiles = false
}
for _, file := range meta.Files {
if strings.HasPrefix(file.OriginData, "http") {
if shouldFetchFiles {
mineType, err := GetFileTypeFromUrl(c, file.OriginData, "token_counter")
if err != nil {
return 0, fmt.Errorf("error getting file base64 from url: %v", err)
@@ -333,28 +344,28 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
} else if strings.HasPrefix(file.OriginData, "data:") {
// get mime type from base64 header
parts := strings.SplitN(file.OriginData, ",", 2)
if len(parts) >= 1 {
header := parts[0]
// Extract mime type from "data:mime/type;base64" format
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mineType := header[mimeStart:mimeEnd]
if strings.HasPrefix(mineType, "image/") {
file.FileType = types.FileTypeImage
} else if strings.HasPrefix(mineType, "video/") {
file.FileType = types.FileTypeVideo
} else if strings.HasPrefix(mineType, "audio/") {
file.FileType = types.FileTypeAudio
} else {
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
}
} else if strings.HasPrefix(file.OriginData, "data:") {
// get mime type from base64 header
parts := strings.SplitN(file.OriginData, ",", 2)
if len(parts) >= 1 {
header := parts[0]
// Extract mime type from "data:mime/type;base64" format
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mineType := header[mimeStart:mimeEnd]
if strings.HasPrefix(mineType, "image/") {
file.FileType = types.FileTypeImage
} else if strings.HasPrefix(mineType, "video/") {
file.FileType = types.FileTypeVideo
} else if strings.HasPrefix(mineType, "audio/") {
file.FileType = types.FileTypeAudio
} else {
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
}
}
}
@@ -365,7 +376,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
switch file.FileType {
case types.FileTypeImage:
if info.RelayFormat == types.RelayFormatGemini {
tkm += 256
tkm += 520 // gemini per input image tokens
} else {
token, err := getImageToken(file, model, info.IsStream)
if err != nil {

View File

@@ -1,7 +1,10 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/gin-gonic/gin"
)
//func GetPromptTokens(textRequest dto.GeneralOpenAIRequest, relayMode int) (int, error) {
@@ -16,7 +19,8 @@ import (
// return 0, errors.New("unknown relay mode")
//}
func ResponseText2Usage(responseText string, modeName string, promptTokens int) *dto.Usage {
func ResponseText2Usage(c *gin.Context, responseText string, modeName string, promptTokens int) *dto.Usage {
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
usage := &dto.Usage{}
usage.PromptTokens = promptTokens
ctkm := CountTextToken(responseText, modeName)

View File

@@ -50,9 +50,18 @@ func GetClaudeSettings() *ClaudeSettings {
func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) {
if headers, ok := c.HeadersSettings[originModel]; ok {
for headerKey, headerValues := range headers {
httpHeader.Del(headerKey)
// get existing values for this header key
existingValues := httpHeader.Values(headerKey)
existingValuesMap := make(map[string]bool)
for _, v := range existingValues {
existingValuesMap[v] = true
}
// add only values that don't already exist
for _, headerValue := range headerValues {
httpHeader.Add(headerKey, headerValue)
if !existingValuesMap[headerValue] {
httpHeader.Add(headerKey, headerValue)
}
}
}
}

View File

@@ -11,13 +11,13 @@ type GeminiSettings struct {
SupportedImagineModels []string `json:"supported_imagine_models"`
ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"`
ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"`
FunctionCallThoughtSignatureEnabled bool `json:"function_call_thought_signature_enabled"`
}
// 默认配置
var defaultGeminiSettings = GeminiSettings{
SafetySettings: map[string]string{
"default": "OFF",
"HARM_CATEGORY_CIVIC_INTEGRITY": "BLOCK_NONE",
"default": "OFF",
},
VersionSettings: map[string]string{
"default": "v1beta",
@@ -29,6 +29,7 @@ var defaultGeminiSettings = GeminiSettings{
},
ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6,
FunctionCallThoughtSignatureEnabled: true,
}
// 全局实例

View File

@@ -55,6 +55,8 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-1-20250805-thinking": 0.1,
"claude-sonnet-4-5-20250929": 0.1,
"claude-sonnet-4-5-20250929-thinking": 0.1,
"claude-opus-4-5-20251101": 0.1,
"claude-opus-4-5-20251101-thinking": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -74,6 +76,8 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-1-20250805-thinking": 1.25,
"claude-sonnet-4-5-20250929": 1.25,
"claude-sonnet-4-5-20250929-thinking": 1.25,
"claude-opus-4-5-20251101": 1.25,
"claude-opus-4-5-20251101-thinking": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}

View File

@@ -143,6 +143,7 @@ var defaultModelRatio = map[string]float64{
"claude-3-7-sonnet-20250219-thinking": 1.5,
"claude-sonnet-4-20250514": 1.5,
"claude-sonnet-4-5-20250929": 1.5,
"claude-opus-4-5-20251101": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
@@ -598,6 +599,11 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-3-pro") {
if strings.HasPrefix(name, "gemini-3-pro-image") {
return 60, false
}
return 6, false
}
return 4, false
}
@@ -823,3 +829,16 @@ func FormatMatchingModelName(name string) string {
}
return name
}
// result: 倍率or价格 usePrice exist
func GetModelRatioOrPrice(model string) (float64, bool, bool) { // price or ratio
price, usePrice := GetModelPrice(model, false)
if usePrice {
return price, true, true
}
modelRatio, success, _ := GetModelRatio(model)
if success {
return modelRatio, false, true
}
return 37.5, false, false
}

View File

@@ -0,0 +1,21 @@
package system_setting
import "github.com/QuantumNous/new-api/setting/config"
type DiscordSettings struct {
Enabled bool `json:"enabled"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// 默认配置
var defaultDiscordSettings = DiscordSettings{}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("discord", &defaultDiscordSettings)
}
func GetDiscordSettings() *DiscordSettings {
return &defaultDiscordSettings
}

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "react-template",

View File

@@ -192,6 +192,14 @@ function App() {
</Suspense>
}
/>
<Route
path='/oauth/discord'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<OAuth2Callback type='discord'></OAuth2Callback>
</Suspense>
}
/>
<Route
path='/oauth/oidc'
element={

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User';
import {
@@ -30,6 +30,7 @@ import {
getSystemName,
setUserData,
onGitHubOAuthClicked,
onDiscordOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
prepareCredentialRequestOptions,
@@ -53,6 +54,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import TwoFAVerification from './TwoFAVerification';
import { useTranslation } from 'react-i18next';
import { SiDiscord }from 'react-icons/si';
const LoginForm = () => {
let navigate = useNavigate();
@@ -73,6 +75,7 @@ const LoginForm = () => {
const [showEmailLogin, setShowEmailLogin] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [discordLoading, setDiscordLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
@@ -87,6 +90,9 @@ const LoginForm = () => {
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [hasUserAgreement, setHasUserAgreement] = useState(false);
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
const [githubButtonText, setGithubButtonText] = useState('使用 GitHub 继续');
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
const githubTimeoutRef = useRef(null);
const logo = getLogo();
const systemName = getSystemName();
@@ -116,6 +122,12 @@ const LoginForm = () => {
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
};
}, []);
useEffect(() => {
@@ -267,7 +279,20 @@ const LoginForm = () => {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
if (githubButtonDisabled) {
return;
}
setGithubLoading(true);
setGithubButtonDisabled(true);
setGithubButtonText(t('正在跳转 GitHub...'));
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
githubTimeoutRef.current = setTimeout(() => {
setGithubLoading(false);
setGithubButtonText(t('请求超时,请刷新页面后重新发起 GitHub 登录'));
setGithubButtonDisabled(true);
}, 20000);
try {
onGitHubOAuthClicked(status.github_client_id);
} finally {
@@ -276,6 +301,21 @@ const LoginForm = () => {
}
};
// 包装的Discord登录点击处理
const handleDiscordClick = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setDiscordLoading(true);
try {
onDiscordOAuthClicked(status.discord_client_id);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setDiscordLoading(false), 3000);
}
};
// 包装的OIDC登录点击处理
const handleOIDCClick = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
@@ -444,8 +484,22 @@ const LoginForm = () => {
icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick}
loading={githubLoading}
disabled={githubButtonDisabled}
>
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
<span className='ml-3'>{githubButtonText}</span>
</Button>
)}
{status.discord_oauth && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
onClick={handleDiscordClick}
loading={discordLoading}
>
<span className='ml-3'>{t('使用 Discord 继续')}</span>
</Button>
)}
@@ -691,6 +745,7 @@ const LoginForm = () => {
</Form>
{(status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
@@ -826,6 +881,7 @@ const LoginForm = () => {
{showEmailLogin ||
!(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
@@ -28,6 +28,7 @@ import {
updateAPI,
getSystemName,
setUserData,
onDiscordOAuthClicked,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
@@ -51,6 +52,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User';
import { useTranslation } from 'react-i18next';
import { SiDiscord } from 'react-icons/si';
const RegisterForm = () => {
let navigate = useNavigate();
@@ -72,6 +74,7 @@ const RegisterForm = () => {
const [showEmailRegister, setShowEmailRegister] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [discordLoading, setDiscordLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
@@ -85,6 +88,9 @@ const RegisterForm = () => {
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [hasUserAgreement, setHasUserAgreement] = useState(false);
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
const [githubButtonText, setGithubButtonText] = useState('使用 GitHub 继续');
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
const githubTimeoutRef = useRef(null);
const logo = getLogo();
const systemName = getSystemName();
@@ -128,6 +134,14 @@ const RegisterForm = () => {
return () => clearInterval(countdownInterval); // Clean up on unmount
}, [disableButton, countdown]);
useEffect(() => {
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
};
}, []);
const onWeChatLoginClicked = () => {
setWechatLoading(true);
setShowWeChatLoginModal(true);
@@ -232,7 +246,20 @@ const RegisterForm = () => {
};
const handleGitHubClick = () => {
if (githubButtonDisabled) {
return;
}
setGithubLoading(true);
setGithubButtonDisabled(true);
setGithubButtonText(t('正在跳转 GitHub...'));
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
githubTimeoutRef.current = setTimeout(() => {
setGithubLoading(false);
setGithubButtonText(t('请求超时,请刷新页面后重新发起 GitHub 登录'));
setGithubButtonDisabled(true);
}, 20000);
try {
onGitHubOAuthClicked(status.github_client_id);
} finally {
@@ -240,6 +267,15 @@ const RegisterForm = () => {
}
};
const handleDiscordClick = () => {
setDiscordLoading(true);
try {
onDiscordOAuthClicked(status.discord_client_id);
} finally {
setTimeout(() => setDiscordLoading(false), 3000);
}
};
const handleOIDCClick = () => {
setOidcLoading(true);
try {
@@ -347,8 +383,22 @@ const RegisterForm = () => {
icon={<IconGithubLogo size='large' />}
onClick={handleGitHubClick}
loading={githubLoading}
disabled={githubButtonDisabled}
>
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
<span className='ml-3'>{githubButtonText}</span>
</Button>
)}
{status.discord_oauth && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
onClick={handleDiscordClick}
loading={discordLoading}
>
<span className='ml-3'>{t('使用 Discord 继续')}</span>
</Button>
)}
@@ -566,6 +616,7 @@ const RegisterForm = () => {
</Form>
{(status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
@@ -661,6 +712,7 @@ const RegisterForm = () => {
{showEmailRegister ||
!(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||

View File

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
import { Languages } from 'lucide-react';
import { CN, GB, FR, RU, JP } from 'country-flag-icons/react/3x2';
import { CN, GB, FR, RU, JP, VN } from 'country-flag-icons/react/3x2';
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
return (
@@ -65,6 +65,13 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
<RU title='Русский' className='!w-5 !h-auto' />
<span>Русский</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('vi')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'vi' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<VN title='Tiếng Việt' className='!w-5 !h-auto' />
<span>Tiếng Việt</span>
</Dropdown.Item>
</Dropdown.Menu>
}
>

View File

@@ -17,12 +17,87 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import React, { useRef, useEffect, useCallback } from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { usePlayground } from '../../contexts/PlaygroundContext';
const CustomInputRender = (props) => {
const { t } = useTranslation();
const { onPasteImage, imageEnabled } = usePlayground();
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
const containerRef = useRef(null);
const handlePaste = useCallback(async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
try {
if (!imageEnabled) {
Toast.warning({
content: t('请先在设置中启用图片功能'),
duration: 3,
});
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
if (onPasteImage) {
onPasteImage(base64);
Toast.success({
content: t('图片已添加'),
duration: 2,
});
} else {
Toast.error({
content: t('无法添加图片'),
duration: 2,
});
}
};
reader.onerror = () => {
console.error('Failed to read image file:', reader.error);
Toast.error({
content: t('粘贴图片失败'),
duration: 2,
});
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Failed to paste image:', error);
Toast.error({
content: t('粘贴图片失败'),
duration: 2,
});
}
}
break;
}
}
}, [onPasteImage, imageEnabled, t]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('paste', handlePaste);
return () => {
container.removeEventListener('paste', handlePaste);
};
}, [handlePaste]);
// 清空按钮
const styledClearNode = clearContextNode
@@ -57,11 +132,12 @@ const CustomInputRender = (props) => {
});
return (
<div className='p-2 sm:p-4'>
<div className='p-2 sm:p-4' ref={containerRef}>
<div
className='flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow'
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
title={t('支持 Ctrl+V 粘贴图片')}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}

View File

@@ -82,7 +82,7 @@ const CustomRequestEditor = ({
return true;
} catch (error) {
setIsValid(false);
setErrorMessage(`JSON格式错误: ${error.message}`);
setErrorMessage(`${t('JSON格式错误')}: ${error.message}`);
return false;
}
};
@@ -123,14 +123,14 @@ const CustomRequestEditor = ({
<div className='flex items-center gap-2'>
<Code size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
自定义请求体模式
{t('自定义请求体模式')}
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText='开'
uncheckedText='关'
checkedText={t('开')}
uncheckedText={t('关')}
size='small'
/>
</div>
@@ -140,7 +140,7 @@ const CustomRequestEditor = ({
{/* 提示信息 */}
<Banner
type='warning'
description='启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。'
description={t('启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。')}
icon={<AlertTriangle size={16} />}
className='!rounded-lg'
closeIcon={null}
@@ -150,21 +150,21 @@ const CustomRequestEditor = ({
<div>
<div className='flex items-center justify-between mb-2'>
<Typography.Text strong className='text-sm'>
请求体 JSON
{t('请求体 JSON')}
</Typography.Text>
<div className='flex items-center gap-2'>
{isValid ? (
<div className='flex items-center gap-1 text-green-600'>
<Check size={14} />
<Typography.Text className='text-xs'>
格式正确
{t('格式正确')}
</Typography.Text>
</div>
) : (
<div className='flex items-center gap-1 text-red-600'>
<X size={14} />
<Typography.Text className='text-xs'>
格式错误
{t('格式错误')}
</Typography.Text>
</div>
)}
@@ -177,7 +177,7 @@ const CustomRequestEditor = ({
disabled={!isValid}
className='!rounded-lg'
>
格式化
{t('格式化')}
</Button>
</div>
</div>
@@ -201,7 +201,7 @@ const CustomRequestEditor = ({
)}
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
{t('请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式。')}
</Typography.Text>
</div>
</>

View File

@@ -29,6 +29,7 @@ import {
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer';
import SSEViewer from './SSEViewer';
const DebugPanel = ({
debugData,
@@ -180,15 +181,27 @@ const DebugPanel = ({
<div className='flex items-center gap-2'>
<Zap size={16} />
{t('响应')}
{debugData.sseMessages && debugData.sseMessages.length > 0 && (
<span className='px-1.5 py-0.5 text-xs bg-blue-100 text-blue-600 rounded-full'>
SSE ({debugData.sseMessages.length})
</span>
)}
</div>
}
itemKey='response'
>
<CodeViewer
content={debugData.response}
title='response'
language='json'
/>
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
<SSEViewer
sseData={debugData.sseMessages}
title='response'
/>
) : (
<CodeViewer
content={debugData.response}
title='response'
language='json'
/>
)}
</TabPane>
</Tabs>
</div>

View File

@@ -21,6 +21,7 @@ import React from 'react';
import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import { FileText, Plus, X, Image } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ImageUrlInput = ({
imageUrls,
@@ -29,6 +30,7 @@ const ImageUrlInput = ({
onImageEnabledChange,
disabled = false,
}) => {
const { t } = useTranslation();
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
@@ -56,11 +58,11 @@ const ImageUrlInput = ({
}
/>
<Typography.Text strong className='text-sm'>
图片地址
{t('图片地址')}
</Typography.Text>
{disabled && (
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
@@ -68,8 +70,8 @@ const ImageUrlInput = ({
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText='启用'
uncheckedText='停用'
checkedText={t('启用')}
uncheckedText={t('停用')}
size='small'
className='flex-shrink-0'
disabled={disabled}
@@ -89,19 +91,19 @@ const ImageUrlInput = ({
{!imageEnabled ? (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled
? '图片功能在自定义请求体模式下不可用'
: '启用后可添加图片URL进行多模态对话'}
? t('图片功能在自定义请求体模式下不可用')
: t('启用后可添加图片URL进行多模态对话')}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled
? '图片功能在自定义请求体模式下不可用'
: '点击 + 按钮添加图片URL进行多模态对话'}
? t('图片功能在自定义请求体模式下不可用')
: t('点击 + 按钮添加图片URL进行多模态对话')}
</Typography.Text>
) : (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
已添加 {imageUrls.length} 张图片
{disabled ? ' (自定义模式下不可用)' : ''}
{t('已添加')} {imageUrls.length} {t('张图片')}
{disabled ? ` (${t('自定义模式下不可用')})` : ''}
</Typography.Text>
)}

View File

@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
Hash,
Thermometer,
@@ -37,6 +38,8 @@ const ParameterControl = ({
onParameterToggle,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<>
{/* Temperature */}
@@ -70,7 +73,7 @@ const ParameterControl = ({
/>
</div>
<Typography.Text className='text-xs text-gray-500 mb-2'>
控制输出的随机性和创造性
{t('控制输出的随机性和创造性')}
</Typography.Text>
<Slider
step={0.1}
@@ -110,7 +113,7 @@ const ParameterControl = ({
/>
</div>
<Typography.Text className='text-xs text-gray-500 mb-2'>
核采样控制词汇选择的多样性
{t('核采样,控制词汇选择的多样性')}
</Typography.Text>
<Slider
step={0.1}
@@ -154,7 +157,7 @@ const ParameterControl = ({
/>
</div>
<Typography.Text className='text-xs text-gray-500 mb-2'>
频率惩罚减少重复词汇的出现
{t('频率惩罚,减少重复词汇的出现')}
</Typography.Text>
<Slider
step={0.1}
@@ -198,7 +201,7 @@ const ParameterControl = ({
/>
</div>
<Typography.Text className='text-xs text-gray-500 mb-2'>
存在惩罚鼓励讨论新话题
{t('存在惩罚,鼓励讨论新话题')}
</Typography.Text>
<Slider
step={0.1}
@@ -262,7 +265,7 @@ const ParameterControl = ({
Seed
</Typography.Text>
<Typography.Text className='text-xs text-gray-400'>
(可选用于复现结果)
({t('可选,用于复现结果')})
</Typography.Text>
</div>
<Button
@@ -276,7 +279,7 @@ const ParameterControl = ({
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
placeholder={t('随机种子 (留空为随机)')}
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}

View File

@@ -0,0 +1,266 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useMemo, useCallback } from 'react';
import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { copy } from '../../helpers';
/**
* SSEViewer component for displaying Server-Sent Events in an interactive format
* @param {Object} props - Component props
* @param {Array} props.sseData - Array of SSE messages to display
* @returns {JSX.Element} Rendered SSE viewer component
*/
const SSEViewer = ({ sseData }) => {
const { t } = useTranslation();
const [expandedKeys, setExpandedKeys] = useState([]);
const [copied, setCopied] = useState(false);
const parsedSSEData = useMemo(() => {
if (!sseData || !Array.isArray(sseData)) {
return [];
}
return sseData.map((item, index) => {
let parsed = null;
let error = null;
let isDone = false;
if (item === '[DONE]') {
isDone = true;
} else {
try {
parsed = typeof item === 'string' ? JSON.parse(item) : item;
} catch (e) {
error = e.message;
}
}
return {
index,
raw: item,
parsed,
error,
isDone,
key: `sse-${index}`,
};
});
}, [sseData]);
const stats = useMemo(() => {
const total = parsedSSEData.length;
const errors = parsedSSEData.filter(item => item.error).length;
const done = parsedSSEData.filter(item => item.isDone).length;
const valid = total - errors - done;
return { total, errors, done, valid };
}, [parsedSSEData]);
const handleToggleAll = useCallback(() => {
setExpandedKeys(prev => {
if (prev.length === parsedSSEData.length) {
return [];
} else {
return parsedSSEData.map(item => item.key);
}
});
}, [parsedSSEData]);
const handleCopyAll = useCallback(async () => {
try {
const allData = parsedSSEData
.map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw))
.join('\n\n');
await copy(allData);
setCopied(true);
Toast.success(t('已复制全部数据'));
setTimeout(() => setCopied(false), 2000);
} catch (err) {
Toast.error(t('复制失败'));
console.error('Copy failed:', err);
}
}, [parsedSSEData, t]);
const handleCopySingle = useCallback(async (item) => {
try {
const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
await copy(textToCopy);
Toast.success(t('已复制'));
} catch (err) {
Toast.error(t('复制失败'));
}
}, [t]);
const renderSSEItem = (item) => {
if (item.isDone) {
return (
<div className='flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg'>
<CheckCircle size={16} className='text-green-600' />
<Typography.Text className='text-green-600 font-medium'>
{t('流式响应完成')} [DONE]
</Typography.Text>
</div>
);
}
if (item.error) {
return (
<div className='space-y-2'>
<div className='flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg'>
<XCircle size={16} className='text-red-600' />
<Typography.Text className='text-red-600'>
{t('解析错误')}: {item.error}
</Typography.Text>
</div>
<div className='p-3 bg-gray-100 dark:bg-gray-800 rounded-lg font-mono text-xs overflow-auto'>
<pre>{item.raw}</pre>
</div>
</div>
);
}
return (
<div className='space-y-2'>
{/* JSON 格式化显示 */}
<div className='relative'>
<pre className='p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-xs font-mono leading-relaxed'>
{JSON.stringify(item.parsed, null, 2)}
</pre>
<Button
icon={<Copy size={12} />}
size='small'
theme='borderless'
onClick={() => handleCopySingle(item)}
className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
/>
</div>
{/* 关键信息摘要 */}
{item.parsed?.choices?.[0] && (
<div className='flex flex-wrap gap-2 text-xs'>
{item.parsed.choices[0].delta?.content && (
<Badge count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`} type='primary' />
)}
{item.parsed.choices[0].delta?.reasoning_content && (
<Badge count={t('有 Reasoning')} type='warning' />
)}
{item.parsed.choices[0].finish_reason && (
<Badge count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`} type='success' />
)}
{item.parsed.usage && (
<Badge
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
type='tertiary'
/>
)}
</div>
)}
</div>
);
};
if (!parsedSSEData || parsedSSEData.length === 0) {
return (
<div className='flex items-center justify-center h-full min-h-[200px] text-gray-500'>
<span>{t('暂无SSE响应数据')}</span>
</div>
);
}
return (
<div className='h-full flex flex-col bg-gray-50 dark:bg-gray-900/50 rounded-lg'>
{/* 头部工具栏 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'>
<div className='flex items-center gap-3'>
<Zap size={16} className='text-blue-500' />
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
<Badge count={stats.total} type='primary' />
{stats.errors > 0 && <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />}
</div>
<div className='flex items-center gap-2'>
<Tooltip content={t('复制全部')}>
<Button
icon={<Copy size={14} />}
size='small'
onClick={handleCopyAll}
theme='borderless'
>
{copied ? t('已复制') : t('复制全部')}
</Button>
</Tooltip>
<Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
<Button
icon={expandedKeys.length === parsedSSEData.length ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
size='small'
onClick={handleToggleAll}
theme='borderless'
>
{expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
</Button>
</Tooltip>
</div>
</div>
{/* SSE 数据列表 */}
<div className='flex-1 overflow-auto p-4'>
<Collapse
activeKey={expandedKeys}
onChange={setExpandedKeys}
accordion={false}
className='bg-white dark:bg-gray-800 rounded-lg'
>
{parsedSSEData.map((item) => (
<Collapse.Panel
key={item.key}
header={
<div className='flex items-center gap-2'>
<Badge count={`#${item.index + 1}`} type='tertiary' />
{item.isDone ? (
<span className='text-green-600 font-medium'>[DONE]</span>
) : item.error ? (
<span className='text-red-600'>{t('解析错误')}</span>
) : (
<>
<span className='text-gray-600'>
{item.parsed?.id || item.parsed?.object || t('SSE 事件')}
</span>
{item.parsed?.choices?.[0]?.delta && (
<span className='text-xs text-gray-400'>
{Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')}
</span>
)}
</>
)}
</div>
}
>
{renderSSEItem(item)}
</Collapse.Panel>
))}
</Collapse>
</div>
</div>
);
};
export default SSEViewer;

View File

@@ -122,7 +122,7 @@ const SettingsPanel = ({
</Typography.Text>
{customRequestMode && (
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
@@ -154,7 +154,7 @@ const SettingsPanel = ({
</Typography.Text>
{customRequestMode && (
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
@@ -206,19 +206,19 @@ const SettingsPanel = ({
<div className='flex items-center gap-2'>
<ToggleLeft size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
流式输出
{t('流式输出')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className='text-xs text-orange-600'>
(已在自定义模式中忽略)
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText='开'
uncheckedText='关'
checkedText={t('开')}
uncheckedText={t('关')}
size='small'
disabled={customRequestMode}
/>

View File

@@ -23,6 +23,7 @@ export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as SSEViewer } from './SSEViewer';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';

View File

@@ -52,6 +52,9 @@ const SystemSetting = () => {
GitHubOAuthEnabled: '',
GitHubClientId: '',
GitHubClientSecret: '',
'discord.enabled': '',
'discord.client_id': '',
'discord.client_secret': '',
'oidc.enabled': '',
'oidc.client_id': '',
'oidc.client_secret': '',
@@ -179,6 +182,7 @@ const SystemSetting = () => {
case 'EmailAliasRestrictionEnabled':
case 'SMTPSSLEnabled':
case 'LinuxDOOAuthEnabled':
case 'discord.enabled':
case 'oidc.enabled':
case 'passkey.enabled':
case 'passkey.allow_insecure_origin':
@@ -473,6 +477,27 @@ const SystemSetting = () => {
}
};
const submitDiscordOAuth = async () => {
const options = [];
if (originInputs['discord.client_id'] !== inputs['discord.client_id']) {
options.push({ key: 'discord.client_id', value: inputs['discord.client_id'] });
}
if (
originInputs['discord.client_secret'] !== inputs['discord.client_secret'] &&
inputs['discord.client_secret'] !== ''
) {
options.push({
key: 'discord.client_secret',
value: inputs['discord.client_secret'],
});
}
if (options.length > 0) {
await updateOptions(options);
}
};
const submitOIDCSettings = async () => {
if (inputs['oidc.well_known'] && inputs['oidc.well_known'] !== '') {
if (
@@ -1014,6 +1039,15 @@ const SystemSetting = () => {
>
{t('允许通过 GitHub 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='discord.enabled'
noLabel
onChange={(e) =>
handleCheckboxChange('discord.enabled', e)
}
>
{t('允许通过 Discord 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='LinuxDOOAuthEnabled'
noLabel
@@ -1410,6 +1444,37 @@ const SystemSetting = () => {
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 Discord OAuth')}>
<Text>{t('用以支持通过 Discord 进行登录注册')}</Text>
<Banner
type='info'
description={`${t('Homepage URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}${t('Authorization callback URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/discord`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['discord.client_id']"
label={t('Discord Client ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['discord.client_secret']"
label={t('Discord Client Secret')}
type='password'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitDiscordOAuth}>
{t('保存 Discord OAuth 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 Linux DO OAuth')}>
<Text>

Some files were not shown because too many files have changed in this diff Show More