Compare commits

...

101 Commits

Author SHA1 Message Date
Calcium-Ion
d3ca454c3b Merge pull request #2348 from QuantumNous/openapi
chore: update openapi files
2025-12-02 00:32:17 +08:00
t0ng7u
46aca8fad3 chore: update openapi files 2025-12-01 21:39:09 +08:00
Calcium-Ion
86aeb72549 Merge pull request #2346 from QuantumNous/nano-banana-multi-turn
feat(gemini): implement markdown image handling in text processing
2025-12-01 18:42:51 +08:00
CaIon
4dbdbdec1d feat(gemini): implement markdown image handling in text processing 2025-12-01 17:54:41 +08:00
CaIon
36a739e777 Remove outdated API documentation for authentication, web API, and models (Midjourney, Rerank, Suno). Add OpenAPI specifications for backend management and relay interfaces. 2025-11-30 21:44:05 +08:00
CaIon
98f92f990a feat(gemini): add validation and conversion for imageConfig parameters in extra_body 2025-11-30 19:31:08 +08:00
CaIon
3f7ea1fd83 fix(vertex): ensure sampleCount is a positive integer and update OtherRatios 2025-11-30 19:05:33 +08:00
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
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
wujiacheng
d9b5748f80 fix: 错误解析responses api中的input字段 2025-11-14 09:58:39 +08: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
112 changed files with 20379 additions and 931 deletions

View File

@@ -63,7 +63,7 @@
# 是否统计图片token
# GET_MEDIA_TOKEN=true
# 是否在非流stream=false情况下统计图片token
# GET_MEDIA_TOKEN_NOT_STREAM=true
# GET_MEDIA_TOKEN_NOT_STREAM=false
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true

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

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ 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

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

@@ -11,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/volcengine"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
@@ -92,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
}
@@ -192,10 +191,20 @@ 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 baseURL == volcengine.DoubaoCodingPlan {
url = fmt.Sprintf("%s/v1/models", volcengine.DoubaoCodingPlanOpenAIBaseURL)
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)
}
@@ -203,9 +212,19 @@ func FetchUpstreamModels(c *gin.Context) {
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))
@@ -278,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...)
}
@@ -1028,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

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

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

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

@@ -1,53 +0,0 @@
# API 鉴权文档
## 认证方式
### Access Token
对于需要鉴权的 API 接口,必须同时提供以下两个请求头来进行 Access Token 认证:
1. **请求头中的 `Authorization` 字段**
将 Access Token 放置于 HTTP 请求头部的 `Authorization` 字段中,格式如下:
```
Authorization: <your_access_token>
```
其中 `<your_access_token>` 需要替换为实际的 Access Token 值。
2. **请求头中的 `New-Api-User` 字段**
将用户 ID 放置于 HTTP 请求头部的 `New-Api-User` 字段中,格式如下:
```
New-Api-User: <your_user_id>
```
其中 `<your_user_id>` 需要替换为实际的用户 ID。
**注意:**
* **必须同时提供 `Authorization` 和 `New-Api-User` 两个请求头才能通过鉴权。**
* 如果只提供其中一个请求头,或者两个请求头都未提供,则会返回 `401 Unauthorized` 错误。
* 如果 `Authorization` 中的 Access Token 无效,则会返回 `401 Unauthorized` 错误并提示“无权进行此操作access token 无效”。
* 如果 `New-Api-User` 中的用户 ID 与 Access Token 不匹配,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,与登录用户不匹配,请重新登录”。
* 如果没有提供 `New-Api-User` 请求头,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,未提供 New-Api-User”。
* 如果 `New-Api-User` 请求头格式错误,则会返回 `401 Unauthorized` 错误并提示“无权进行此操作New-Api-User 格式错误”。
* 如果用户已被禁用,则会返回 `403 Forbidden` 错误,并提示“用户已被封禁”。
* 如果用户权限不足,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,权限不足”。
* 如果用户信息无效,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,用户信息无效”。
## Curl 示例
假设您的 Access Token 为 `access_token`,用户 ID 为 `123`,要访问的 API 接口为 `/api/user/self`,则可以使用以下 curl 命令:
```bash
curl -X GET \
-H "Authorization: access_token" \
-H "New-Api-User: 123" \
https://your-domain.com/api/user/self
```
请将 `access_token`、`123` 和 `https://your-domain.com` 替换为实际的值。

View File

@@ -1,197 +0,0 @@
# New API Web 界面后端接口文档
> 本文档汇总了 **New API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
>
> 接口前缀统一为 `https://<your-domain>`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。
>
> 鉴权级别说明:
> * **公开** 不需要登录即可调用
> * **用户** 需携带用户 Token`middleware.UserAuth`
> * **管理员** 需管理员 Token`middleware.AdminAuth`
> * **Root** 仅限最高权限 Root 用户(`middleware.RootAuth`
---
## 1. 初始化 / 系统状态
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/setup | 公开 | 获取系统初始化状态 |
| POST | /api/setup | 公开 | 完成首次安装向导 |
| GET | /api/status | 公开 | 获取运行状态摘要 |
| GET | /api/uptime/status | 公开 | Uptime-Kuma 兼容状态探针 |
| GET | /api/status/test | 管理员 | 测试后端与依赖组件是否正常 |
## 2. 公共信息
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/models | 用户 | 获取前端可用模型列表 |
| GET | /api/notice | 公开 | 获取公告栏内容 |
| GET | /api/about | 公开 | 关于页面信息 |
| GET | /api/home_page_content | 公开 | 首页自定义内容 |
| GET | /api/pricing | 可匿名/用户 | 价格与套餐信息 |
| GET | /api/ratio_config | 公开 | 模型倍率配置(仅公开字段) |
## 3. 邮件 / 身份验证
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/verification | 公开 (限流) | 发送邮箱验证邮件 |
| GET | /api/reset_password | 公开 (限流) | 发送重置密码邮件 |
| POST | /api/user/reset | 公开 | 提交重置密码请求 |
## 4. OAuth / 第三方登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |
| GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 |
| GET | /api/oauth/email/bind | 公开 | 邮箱绑定 |
| GET | /api/oauth/telegram/login | 公开 | Telegram 登录 |
| GET | /api/oauth/telegram/bind | 公开 | Telegram 账户绑定 |
| GET | /api/oauth/state | 公开 | 获取随机 state防 CSRF |
## 5. 用户模块
### 5.1 账号注册/登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | /api/user/register | 公开 | 注册新账号 |
| POST | /api/user/login | 公开 | 用户登录 |
| GET | /api/user/logout | 用户 | 退出登录 |
| GET | /api/user/epay/notify | 公开 | Epay 支付回调 |
| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) |
### 5.2 用户自身操作 (需登录)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/self/groups | 用户 | 获取自己所在分组 |
| GET | /api/user/self | 用户 | 获取个人资料 |
| GET | /api/user/models | 用户 | 获取模型可见性 |
| PUT | /api/user/self | 用户 | 修改个人资料 |
| DELETE | /api/user/self | 用户 | 注销账号 |
| GET | /api/user/token | 用户 | 生成用户级别 Access Token |
| GET | /api/user/aff | 用户 | 获取推广码信息 |
| POST | /api/user/topup | 用户 | 余额直充 |
| POST | /api/user/pay | 用户 | 提交支付订单 |
| POST | /api/user/amount | 用户 | 余额支付 |
| POST | /api/user/aff_transfer | 用户 | 推广额度转账 |
| PUT | /api/user/setting | 用户 | 更新用户设置 |
### 5.3 管理员用户管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/ | 管理员 | 获取全部用户列表 |
| GET | /api/user/search | 管理员 | 搜索用户 |
| GET | /api/user/:id | 管理员 | 获取单个用户信息 |
| POST | /api/user/ | 管理员 | 创建用户 |
| POST | /api/user/manage | 管理员 | 冻结/重置等管理操作 |
| PUT | /api/user/ | 管理员 | 更新用户 |
| DELETE | /api/user/:id | 管理员 | 删除用户 |
## 6. 站点选项 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/option/ | Root | 获取全局配置 |
| PUT | /api/option/ | Root | 更新全局配置 |
| POST | /api/option/rest_model_ratio | Root | 重置模型倍率 |
| POST | /api/option/migrate_console_setting | Root | 迁移旧版控制台配置 |
## 7. 模型倍率同步 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/ratio_sync/channels | Root | 获取可同步渠道列表 |
| POST | /api/ratio_sync/fetch | Root | 从上游拉取倍率 |
## 8. 渠道管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/channel/ | 获取渠道列表 |
| GET | /api/channel/search | 搜索渠道 |
| GET | /api/channel/models | 查询渠道模型能力 |
| GET | /api/channel/models_enabled | 查询启用模型能力 |
| GET | /api/channel/:id | 获取单个渠道 |
| GET | /api/channel/test | 批量测试渠道连通性 |
| GET | /api/channel/test/:id | 单个渠道测试 |
| GET | /api/channel/update_balance | 批量刷新余额 |
| GET | /api/channel/update_balance/:id | 单个刷新余额 |
| POST | /api/channel/ | 新增渠道 |
| PUT | /api/channel/ | 更新渠道 |
| DELETE | /api/channel/disabled | 删除已禁用渠道 |
| POST | /api/channel/tag/disabled | 批量禁用标签渠道 |
| POST | /api/channel/tag/enabled | 批量启用标签渠道 |
| PUT | /api/channel/tag | 编辑渠道标签 |
| DELETE | /api/channel/:id | 删除渠道 |
| POST | /api/channel/batch | 批量删除渠道 |
| POST | /api/channel/fix | 修复渠道能力表 |
| GET | /api/channel/fetch_models/:id | 拉取单渠道模型 |
| POST | /api/channel/fetch_models | 拉取全部渠道模型 |
| POST | /api/channel/batch/tag | 批量设置渠道标签 |
| GET | /api/channel/tag/models | 根据标签获取模型 |
| POST | /api/channel/copy/:id | 复制渠道 |
## 9. Token 管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/token/ | 用户 | 获取全部 Token |
| GET | /api/token/search | 用户 | 搜索 Token |
| GET | /api/token/:id | 用户 | 获取单个 Token |
| POST | /api/token/ | 用户 | 创建 Token |
| PUT | /api/token/ | 用户 | 更新 Token |
| DELETE | /api/token/:id | 用户 | 删除 Token |
| POST | /api/token/batch | 用户 | 批量删除 Token |
## 10. 兑换码管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/redemption/ | 获取兑换码列表 |
| GET | /api/redemption/search | 搜索兑换码 |
| GET | /api/redemption/:id | 获取单个兑换码 |
| POST | /api/redemption/ | 创建兑换码 |
| PUT | /api/redemption/ | 更新兑换码 |
| DELETE | /api/redemption/invalid | 删除无效兑换码 |
| DELETE | /api/redemption/:id | 删除兑换码 |
## 11. 日志
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/log/ | 管理员 | 获取全部日志 |
| DELETE | /api/log/ | 管理员 | 删除历史日志 |
| GET | /api/log/stat | 管理员 | 日志统计 |
| GET | /api/log/self/stat | 用户 | 我的日志统计 |
| GET | /api/log/search | 管理员 | 搜索全部日志 |
| GET | /api/log/self | 用户 | 获取我的日志 |
| GET | /api/log/self/search | 用户 | 搜索我的日志 |
| GET | /api/log/token | 公开 | 根据 Token 查询日志(支持 CORS |
## 12. 数据统计
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/data/ | 管理员 | 全站用量按日期统计 |
| GET | /api/data/self | 用户 | 我的用量按日期统计 |
## 13. 分组
| GET | /api/group/ | 管理员 | 获取全部分组列表 |
## 14. Midjourney 任务
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/mj/self | 用户 | 获取自己的 MJ 任务 |
| GET | /api/mj/ | 管理员 | 获取全部 MJ 任务 |
## 15. 任务中心
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/task/self | 用户 | 获取我的任务 |
| GET | /api/task/ | 管理员 | 获取全部任务 |
## 16. 账户计费面板 (Dashboard)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /dashboard/billing/subscription | 用户 Token | 获取订阅额度信息 |
| GET | /v1/dashboard/billing/subscription | 同上 | 兼容 OpenAI SDK 路径 |
| GET | /dashboard/billing/usage | 用户 Token | 获取使用量信息 |
| GET | /v1/dashboard/billing/usage | 同上 | 兼容 OpenAI SDK 路径 |
---
> **更新日期**2025.07.17

View File

@@ -1,82 +0,0 @@
# Midjourney Proxy API文档
**简介**:Midjourney Proxy API文档
## 接口列表
支持的接口如下:
+ [x] /mj/submit/imagine
+ [x] /mj/submit/change
+ [x] /mj/submit/blend
+ [x] /mj/submit/describe
+ [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**
+ [x] /mj/task/{id}/fetch 此接口返回的图片地址为经过One API转发的地址
+ [x] /task/list-by-condition
+ [x] /mj/submit/action 仅midjourney-proxy-plus支持下同
+ [x] /mj/submit/modal
+ [x] /mj/submit/shorten
+ [x] /mj/task/{id}/image-seed
+ [x] /mj/insight-face/swap InsightFace
## 模型列表
### midjourney-proxy支持
- mj_imagine (绘图)
- mj_variation (变换)
- mj_reroll (重绘)
- mj_blend (混合)
- mj_upscale (放大)
- mj_describe (图生文)
### 仅midjourney-proxy-plus支持
- mj_zoom (比例变焦)
- mj_shorten (提示词缩短)
- mj_modal (窗口提交局部重绘和自定义比例变焦必须和mj_modal一同添加)
- mj_inpaint (局部重绘提交必须和mj_modal一同添加)
- mj_custom_zoom (自定义比例变焦必须和mj_modal一同添加)
- mj_high_variation (强变换)
- mj_low_variation (弱变换)
- mj_pan (平移)
- swap_face (换脸)
## 模型价格设置(在设置-运营设置-模型固定价格设置中设置)
```json
{
"mj_imagine": 0.1,
"mj_variation": 0.1,
"mj_reroll": 0.1,
"mj_blend": 0.1,
"mj_modal": 0.1,
"mj_zoom": 0.1,
"mj_shorten": 0.1,
"mj_high_variation": 0.1,
"mj_low_variation": 0.1,
"mj_pan": 0.1,
"mj_inpaint": 0,
"mj_custom_zoom": 0,
"mj_describe": 0.05,
"mj_upscale": 0.05,
"swap_face": 0.05
}
```
其中mj_inpaint和mj_custom_zoom的价格设置为0是因为这两个模型需要搭配mj_modal使用所以价格由mj_modal决定。
## 渠道设置
### 对接 midjourney-proxy(plus)
1.
部署Midjourney-Proxy并配置好midjourney账号等强烈建议设置密钥[项目地址](https://github.com/novicezk/midjourney-proxy)
2. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy**如果是plus版本选择**Midjourney Proxy Plus**
,模型请参考上方模型列表
3. **代理**填写midjourney-proxy部署的地址例如http://localhost:8080
4. 密钥填写midjourney-proxy的密钥如果没有设置密钥可以随便填
### 对接上游new api
1. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy Plus**,模型请参考上方模型列表
2. **代理**填写上游new api的地址例如http://localhost:3000
3. 密钥填写上游new api的密钥

View File

@@ -1,62 +0,0 @@
# Rerank API文档
**简介**:Rerank API文档
## 接入Dify
模型供应商选择Jina按要求填写模型信息即可接入Dify。
## 请求方式
Post: /v1/rerank
Request:
```json
{
"model": "jina-reranker-v2-base-multilingual",
"query": "What is the capital of the United States?",
"top_n": 3,
"documents": [
"Carson City is the capital city of the American state of Nevada.",
"The Commonwealth of the Northern Mariana Islands is a group of islands in the Pacific Ocean. Its capital is Saipan.",
"Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district.",
"Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages.",
"Capital punishment (the death penalty) has existed in the United States since beforethe United States was a country. As of 2017, capital punishment is legal in 30 of the 50 states."
]
}
```
Response:
```json
{
"results": [
{
"document": {
"text": "Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district."
},
"index": 2,
"relevance_score": 0.9999702
},
{
"document": {
"text": "Carson City is the capital city of the American state of Nevada."
},
"index": 0,
"relevance_score": 0.67800725
},
{
"document": {
"text": "Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages."
},
"index": 3,
"relevance_score": 0.02800752
}
],
"usage": {
"prompt_tokens": 158,
"completion_tokens": 0,
"total_tokens": 158
}
}
```

View File

@@ -1,44 +0,0 @@
# Suno API文档
**简介**:Suno API文档
## 接口列表
支持的接口如下:
+ [x] /suno/submit/music
+ [x] /suno/submit/lyrics
+ [x] /suno/fetch
+ [x] /suno/fetch/:id
## 模型列表
### Suno API支持
- suno_music (自定义模式、灵感模式、续写)
- suno_lyrics (生成歌词)
## 模型价格设置(在设置-运营设置-模型固定价格设置中设置)
```json
{
"suno_music": 0.3,
"suno_lyrics": 0.01
}
```
## 渠道设置
### 对接 Suno API
1.
部署 Suno API并配置好suno账号等强烈建议设置密钥[项目地址](https://github.com/Suno-API/Suno-API)
2. 在渠道管理中添加渠道,渠道类型选择**Suno API**
,模型请参考上方模型列表
3. **代理**填写 Suno API 部署的地址例如http://localhost:8080
4. 密钥填写 Suno API 的密钥,如果没有设置密钥,可以随便填
### 对接上游new api
1. 在渠道管理中添加渠道,渠道类型选择**Suno API**,或任意类型,只需模型包含上方模型列表的模型
2. **代理**填写上游new api的地址例如http://localhost:3000
3. 密钥填写上游new api的密钥

7818
docs/openapi/api.json Normal file

File diff suppressed because it is too large Load Diff

7141
docs/openapi/relay.json Normal file

File diff suppressed because it is too large Load Diff

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

@@ -897,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"`
@@ -915,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 {
@@ -925,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

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

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

@@ -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
@@ -173,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{
@@ -199,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()
@@ -238,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",
@@ -202,6 +208,7 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
adaptorWithExtraBody := false
// patch extra_body
if len(textRequest.ExtraBody) > 0 {
if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
var extraBody map[string]interface{}
@@ -233,6 +240,39 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
}
}
}
// check error param name like imageConfig, should be image_config
if _, hasErrorParam := googleBody["imageConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.imageConfig is not supported, use extra_body.google.image_config instead")
}
if imageConfig, ok := googleBody["image_config"].(map[string]interface{}); ok {
// check error param name like aspectRatio, should be aspect_ratio
if _, hasErrorParam := imageConfig["aspectRatio"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.aspectRatio is not supported, use extra_body.google.image_config.aspect_ratio instead")
}
// check error param name like imageSize, should be image_size
if _, hasErrorParam := imageConfig["imageSize"]; hasErrorParam {
return nil, errors.New("extra_body.google.image_config.imageSize is not supported, use extra_body.google.image_config.image_size instead")
}
// convert snake_case to camelCase for Gemini API
geminiImageConfig := make(map[string]interface{})
if aspectRatio, ok := imageConfig["aspect_ratio"]; ok {
geminiImageConfig["aspectRatio"] = aspectRatio
}
if imageSize, ok := imageConfig["image_size"]; ok {
geminiImageConfig["imageSize"] = imageSize
}
if len(geminiImageConfig) > 0 {
imageConfigBytes, err := common.Marshal(geminiImageConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
}
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
}
}
}
}
}
@@ -371,6 +411,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 +430,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
}
@@ -400,9 +446,68 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
if part.Text == "" {
continue
}
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
// check markdown image ![image](data:image/jpeg;base64,xxxxxxxxxxxx)
// 使用字符串查找而非正则,避免大文本性能问题
text := part.Text
hasMarkdownImage := false
for {
// 快速检查是否包含 markdown 图片标记
startIdx := strings.Index(text, "![")
if startIdx == -1 {
break
}
// 找到 ](
bracketIdx := strings.Index(text[startIdx:], "](data:")
if bracketIdx == -1 {
break
}
bracketIdx += startIdx
// 找到闭合的 )
closeIdx := strings.Index(text[bracketIdx+2:], ")")
if closeIdx == -1 {
break
}
closeIdx += bracketIdx + 2
hasMarkdownImage = true
// 添加图片前的文本
if startIdx > 0 {
textBefore := text[:startIdx]
if textBefore != "" {
parts = append(parts, dto.GeminiPart{
Text: textBefore,
})
}
}
// 提取 data URL (从 "](" 后面开始,到 ")" 之前)
dataUrl := text[bracketIdx+2 : closeIdx]
imageNum += 1
if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum {
return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum)
}
format, base64String, err := service.DecodeBase64FileData(dataUrl)
if err != nil {
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
}
imgPart := dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
}
if shouldAttachThoughtSignature {
imgPart.ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
}
parts = append(parts, imgPart)
// 继续处理剩余文本
text = text[closeIdx+1:]
}
// 添加剩余文本或原始文本(如果没有找到 markdown 图片)
if !hasMarkdownImage {
parts = append(parts, dto.GeminiPart{
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
imageNum += 1
@@ -472,6 +577,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 +612,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 +1058,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 +1071,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 +1083,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 +1097,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 +1151,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 +1161,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 +1178,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

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

@@ -12,7 +12,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/QuantumNous/new-api/constant"
@@ -147,13 +146,40 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn
body.Parameters["storageUri"] = v
}
if v, ok := req.Metadata["sampleCount"]; ok {
body.Parameters["sampleCount"] = v
if i, ok := v.(int); ok {
body.Parameters["sampleCount"] = i
}
if f, ok := v.(float64); ok {
body.Parameters["sampleCount"] = int(f)
}
}
}
if _, ok := body.Parameters["sampleCount"]; !ok {
body.Parameters["sampleCount"] = 1
}
if body.Parameters["sampleCount"].(int) <= 0 {
return nil, fmt.Errorf("sampleCount must be greater than 0")
}
// if req.Duration > 0 {
// body.Parameters["durationSeconds"] = req.Duration
// } else if req.Seconds != "" {
// seconds, err := strconv.Atoi(req.Seconds)
// if err != nil {
// return nil, errors.Wrap(err, "convert seconds to int failed")
// }
// body.Parameters["durationSeconds"] = seconds
// }
info.PriceData.OtherRatios = map[string]float64{
"sampleCount": float64(body.Parameters["sampleCount"].(int)),
}
// if v, ok := body.Parameters["durationSeconds"]; ok {
// info.PriceData.OtherRatios["durationSeconds"] = float64(v.(int))
// }
data, err := json.Marshal(body)
if err != nil {
return nil, err

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"
@@ -296,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"
@@ -23,11 +24,8 @@ import (
)
const (
contextKeyTTSRequest = "volcengine_tts_request"
contextKeyResponseFormat = "response_format"
DoubaoCodingPlan = "doubao-coding-plan"
DoubaoCodingPlanClaudeBaseURL = "https://ark.cn-beijing.volces.com/api/coding"
DoubaoCodingPlanOpenAIBaseURL = "https://ark.cn-beijing.volces.com/api/coding/v3"
contextKeyTTSRequest = "volcengine_tts_request"
contextKeyResponseFormat = "response_format"
)
type Adaptor struct {
@@ -39,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)
}
@@ -238,11 +240,12 @@ 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 baseUrl == DoubaoCodingPlan {
return fmt.Sprintf("%s/v1/messages", DoubaoCodingPlanClaudeBaseURL), nil
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
@@ -251,8 +254,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
default:
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if baseUrl == DoubaoCodingPlan {
return fmt.Sprintf("%s/chat/completions", DoubaoCodingPlanOpenAIBaseURL), nil
if 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
@@ -340,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

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

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

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

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>

View File

@@ -38,13 +38,14 @@ import {
IconLock,
IconDelete,
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
import { UserPlus, ShieldCheck } from 'lucide-react';
import TelegramLoginButton from 'react-telegram-login';
import {
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
onDiscordOAuthClicked,
} from '../../../../helpers';
import TwoFASetting from '../components/TwoFASetting';
@@ -247,6 +248,47 @@ const AccountManagement = ({
</div>
</Card>
{/* Discord绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiDiscord
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('Discord')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.discord_id,
t('Discord ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() =>
onDiscordOAuthClicked(status.discord_client_id)
}
disabled={
isBound(userState.user?.discord_id) ||
!status.discord_oauth
}
>
{status.discord_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</Card>
{/* OIDC绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>

View File

@@ -190,6 +190,30 @@ const EditChannelModal = (props) => {
const [keyMode, setKeyMode] = useState('append'); // 密钥模式replace覆盖或 append追加
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
const [doubaoApiEditUnlocked, setDoubaoApiEditUnlocked] = useState(false); // 豆包渠道自定义 API 地址隐藏入口
const redirectModelList = useMemo(() => {
const mapping = inputs.model_mapping;
if (typeof mapping !== 'string') return [];
const trimmed = mapping.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
return [];
}
const values = Object.values(parsed)
.map((value) =>
typeof value === 'string' ? value.trim() : undefined,
)
.filter((value) => value);
return Array.from(new Set(values));
} catch (error) {
return [];
}
}, [inputs.model_mapping]);
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -220,6 +244,8 @@ const EditChannelModal = (props) => {
];
const formContainerRef = useRef(null);
const doubaoApiClickCountRef = useRef(0);
const initialModelsRef = useRef([]);
const initialModelMappingRef = useRef('');
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
@@ -595,6 +621,10 @@ const EditChannelModal = (props) => {
system_prompt: data.system_prompt,
system_prompt_override: data.system_prompt_override || false,
});
initialModelsRef.current = (data.models || [])
.map((model) => (model || '').trim())
.filter(Boolean);
initialModelMappingRef.current = data.model_mapping || '';
// console.log(data);
} else {
showError(message);
@@ -830,6 +860,13 @@ const EditChannelModal = (props) => {
}
}, [props.visible, channelId]);
useEffect(() => {
if (!isEdit) {
initialModelsRef.current = [];
initialModelMappingRef.current = '';
}
}, [isEdit, props.visible]);
// 统一的模态框重置函数
const resetModalState = () => {
formApiRef.current?.reset();
@@ -903,6 +940,80 @@ const EditChannelModal = (props) => {
})();
};
const confirmMissingModelMappings = (missingModels) =>
new Promise((resolve) => {
const modal = Modal.confirm({
title: t('模型未加入列表,可能无法调用'),
content: (
<div className='text-sm leading-6'>
<div>
{t(
'模型重定向里的下列模型尚未添加到“模型”列表,调用时会因为缺少可用模型而失败:',
)}
</div>
<div className='font-mono text-xs break-all text-red-600 mt-1'>
{missingModels.join(', ')}
</div>
<div className='mt-2'>
{t(
'你可以在“自定义模型名称”处手动添加它们,然后点击填入后再提交,或者直接使用下方操作自动处理。',
)}
</div>
</div>
),
centered: true,
footer: (
<Space align='center' className='w-full justify-end'>
<Button
type='tertiary'
onClick={() => {
modal.destroy();
resolve('cancel');
}}
>
{t('返回修改')}
</Button>
<Button
type='primary'
theme='light'
onClick={() => {
modal.destroy();
resolve('submit');
}}
>
{t('直接提交')}
</Button>
<Button
type='primary'
theme='solid'
onClick={() => {
modal.destroy();
resolve('add');
}}
>
{t('添加后提交')}
</Button>
</Space>
),
});
});
const hasModelConfigChanged = (normalizedModels, modelMappingStr) => {
if (!isEdit) return true;
const initialModels = initialModelsRef.current;
if (normalizedModels.length !== initialModels.length) {
return true;
}
for (let i = 0; i < normalizedModels.length; i++) {
if (normalizedModels[i] !== initialModels[i]) {
return true;
}
}
const normalizedMapping = (modelMappingStr || '').trim();
const initialMapping = (initialModelMappingRef.current || '').trim();
return normalizedMapping !== initialMapping;
};
const submit = async () => {
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
let localInputs = { ...formValues };
@@ -986,14 +1097,55 @@ const EditChannelModal = (props) => {
showInfo(t('请输入API地址'));
return;
}
if (
localInputs.model_mapping &&
localInputs.model_mapping !== '' &&
!verifyJSON(localInputs.model_mapping)
) {
showInfo(t('模型映射必须是合法的 JSON 格式!'));
return;
const hasModelMapping =
typeof localInputs.model_mapping === 'string' &&
localInputs.model_mapping.trim() !== '';
let parsedModelMapping = null;
if (hasModelMapping) {
if (!verifyJSON(localInputs.model_mapping)) {
showInfo(t('模型映射必须是合法的 JSON 格式!'));
return;
}
try {
parsedModelMapping = JSON.parse(localInputs.model_mapping);
} catch (error) {
showInfo(t('模型映射必须是合法的 JSON 格式!'));
return;
}
}
const normalizedModels = (localInputs.models || [])
.map((model) => (model || '').trim())
.filter(Boolean);
localInputs.models = normalizedModels;
if (
parsedModelMapping &&
typeof parsedModelMapping === 'object' &&
!Array.isArray(parsedModelMapping)
) {
const modelSet = new Set(normalizedModels);
const missingModels = Object.keys(parsedModelMapping)
.map((key) => (key || '').trim())
.filter((key) => key && !modelSet.has(key));
const shouldPromptMissing =
missingModels.length > 0 &&
hasModelConfigChanged(normalizedModels, localInputs.model_mapping);
if (shouldPromptMissing) {
const confirmAction = await confirmMissingModelMappings(missingModels);
if (confirmAction === 'cancel') {
return;
}
if (confirmAction === 'add') {
const updatedModels = Array.from(
new Set([...normalizedModels, ...missingModels]),
);
localInputs.models = updatedModels;
handleInputChange('models', updatedModels);
}
}
}
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(
0,
@@ -1036,6 +1188,13 @@ const EditChannelModal = (props) => {
settings.aws_key_type = localInputs.aws_key_type || 'ak_sk';
}
// type === 41 (Vertex): 始终保存 vertex_key_type 到 settings避免编辑时被重置
if (localInputs.type === 41) {
settings.vertex_key_type = localInputs.vertex_key_type || 'json';
} else if ('vertex_key_type' in settings) {
delete settings.vertex_key_type;
}
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
if (localInputs.type === 1 || localInputs.type === 14) {
settings.allow_service_tier = localInputs.allow_service_tier === true;
@@ -2916,6 +3075,7 @@ const EditChannelModal = (props) => {
visible={modelModalVisible}
models={fetchedModels}
selected={inputs.models}
redirectModels={redirectModelList}
onConfirm={(selectedModels) => {
handleInputChange('models', selectedModels);
showSuccess(t('模型列表已更新'));

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, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Modal,
@@ -28,12 +28,13 @@ import {
Empty,
Tabs,
Collapse,
Tooltip,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { getModelCategories } from '../../../../helpers/render';
@@ -41,6 +42,7 @@ const ModelSelectModal = ({
visible,
models = [],
selected = [],
redirectModels = [],
onConfirm,
onCancel,
}) => {
@@ -50,15 +52,54 @@ const ModelSelectModal = ({
const [activeTab, setActiveTab] = useState('new');
const isMobile = useIsMobile();
const normalizeModelName = (model) =>
typeof model === 'string' ? model.trim() : '';
const normalizedRedirectModels = useMemo(
() =>
Array.from(
new Set(
(redirectModels || [])
.map((model) => normalizeModelName(model))
.filter(Boolean),
),
),
[redirectModels],
);
const normalizedSelectedSet = useMemo(() => {
const set = new Set();
(selected || []).forEach((model) => {
const normalized = normalizeModelName(model);
if (normalized) {
set.add(normalized);
}
});
return set;
}, [selected]);
const classificationSet = useMemo(() => {
const set = new Set(normalizedSelectedSet);
normalizedRedirectModels.forEach((model) => set.add(model));
return set;
}, [normalizedSelectedSet, normalizedRedirectModels]);
const redirectOnlySet = useMemo(() => {
const set = new Set();
normalizedRedirectModels.forEach((model) => {
if (!normalizedSelectedSet.has(model)) {
set.add(model);
}
});
return set;
}, [normalizedRedirectModels, normalizedSelectedSet]);
const filteredModels = models.filter((m) =>
m.toLowerCase().includes(keyword.toLowerCase()),
String(m || '').toLowerCase().includes(keyword.toLowerCase()),
);
// 分类模型:新获取的模型和已有模型
const newModels = filteredModels.filter((model) => !selected.includes(model));
const isExistingModel = (model) =>
classificationSet.has(normalizeModelName(model));
const newModels = filteredModels.filter((model) => !isExistingModel(model));
const existingModels = filteredModels.filter((model) =>
selected.includes(model),
isExistingModel(model),
);
// 同步外部选中值
@@ -228,7 +269,20 @@ const ModelSelectModal = ({
<div className='grid grid-cols-2 gap-x-4'>
{categoryData.models.map((model) => (
<Checkbox key={model} value={model} className='my-1'>
{model}
<span className='flex items-center gap-2'>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip
position='top'
content={t('来自模型重定向,尚未加入模型列表')}
>
<IconInfoCircle
size='small'
className='text-amber-500 cursor-help'
/>
</Tooltip>
)}
</span>
</Checkbox>
))}
</div>

View File

@@ -363,12 +363,13 @@ export const getTaskLogsColumns = ({
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {
const videoUrl = `/v1/videos/${record.task_id}/content`;
return (
<a
href='#'
onClick={(e) => {
e.preventDefault();
openVideoModal(text);
openVideoModal(videoUrl);
}}
>
{t('点击预览视频')}

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect } from 'react';
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
@@ -29,6 +30,7 @@ const ContentModal = ({
modalContent,
isVideo,
}) => {
const { t } = useTranslation();
const [videoError, setVideoError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -64,25 +66,25 @@ const ContentModal = ({
type='tertiary'
style={{ display: 'block', marginBottom: '16px' }}
>
视频无法在当前浏览器中播放这可能是由于
{t('视频无法在当前浏览器中播放这可能是由于:')}
</Text>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
视频服务商的跨域限制
{t('• 视频服务商的跨域限制')}
</Text>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
>
需要特定的请求头或认证
{t('• 需要特定的请求头或认证')}
</Text>
<Text
type='tertiary'
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
>
防盗链保护机制
{t('• 防盗链保护机制')}
</Text>
<div style={{ marginTop: '20px' }}>
@@ -91,10 +93,10 @@ const ContentModal = ({
onClick={handleOpenInNewTab}
style={{ marginRight: '8px' }}
>
在新标签页中打开
{t('在新标签页中打开')}
</Button>
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
复制链接
{t('复制链接')}
</Button>
</div>

View File

@@ -72,6 +72,7 @@ const EditUserModal = (props) => {
password: '',
github_id: '',
oidc_id: '',
discord_id: '',
wechat_id: '',
telegram_id: '',
email: '',
@@ -332,6 +333,7 @@ const EditUserModal = (props) => {
<Row gutter={12}>
{[
'github_id',
'discord_id',
'oidc_id',
'wechat_id',
'email',

View File

@@ -30,19 +30,37 @@ export const MESSAGE_ROLES = {
SYSTEM: 'system',
};
// 默认消息示例
export const DEFAULT_MESSAGES = [
// 默认消息示例 - 使用函数生成以支持 i18n
export const getDefaultMessages = (t) => [
{
role: MESSAGE_ROLES.USER,
id: '2',
createAt: 1715676751919,
content: '你好',
content: t('默认用户消息'),
},
{
role: MESSAGE_ROLES.ASSISTANT,
id: '3',
createAt: 1715676751919,
content: '你好,请问有什么可以帮助您的吗?',
content: t('默认助手消息'),
reasoningContent: '',
isReasoningExpanded: false,
},
];
// 保留旧的导出以保持向后兼容
export const DEFAULT_MESSAGES = [
{
role: MESSAGE_ROLES.USER,
id: '2',
createAt: 1715676751919,
content: 'Hello',
},
{
role: MESSAGE_ROLES.ASSISTANT,
id: '3',
createAt: 1715676751919,
content: 'Hello! How can I help you today?',
reasoningContent: '',
isReasoningExpanded: false,
},

View File

@@ -0,0 +1,60 @@
/*
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, { createContext, useContext } from 'react';
/**
* Context for Playground component to share image handling functionality
*/
const PlaygroundContext = createContext(null);
/**
* Hook to access Playground context
* @returns {Object} Context value with onPasteImage, imageUrls, and imageEnabled
*/
export const usePlayground = () => {
const context = useContext(PlaygroundContext);
if (!context) {
return {
onPasteImage: () => {
console.warn('PlaygroundContext not provided');
},
imageUrls: [],
imageEnabled: false,
};
}
return context;
};
/**
* Provider component for Playground context
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Child components
* @param {Object} props.value - Context value to provide
* @returns {JSX.Element} Provider component
*/
export const PlaygroundProvider = ({ children, value }) => {
return (
<PlaygroundContext.Provider value={value}>
{children}
</PlaygroundContext.Provider>
);
};
export default PlaygroundContext;

View File

@@ -231,6 +231,17 @@ export async function getOAuthState() {
}
}
export async function onDiscordOAuthClicked(client_id) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/discord`;
const response_type = 'code';
const scope = 'identify+openid';
window.open(
`https://discord.com/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`,
);
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;

View File

@@ -1795,10 +1795,13 @@ export function renderClaudeModelPrice(
// Calculate effective input tokens (non-cached + cached with ratio applied + cache creation with ratio applied)
const nonCachedTokens = inputTokens;
const legacyCacheCreationTokens = hasSplitCacheCreation
? 0
: cacheCreationTokens;
const effectiveInputTokens =
nonCachedTokens +
cacheTokens * cacheRatio +
cacheCreationTokens * cacheCreationRatio +
legacyCacheCreationTokens * cacheCreationRatio +
cacheCreationTokens5m * cacheCreationRatio5m +
cacheCreationTokens1h * cacheCreationRatio1h;

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