Compare commits

...

1184 Commits

Author SHA1 Message Date
Calcium-Ion 91fcdd4f18 Merge pull request #2348 from QuantumNous/openapi
chore: update openapi files
2025-12-02 00:32:17 +08:00
t0ng7u 52dd93804c chore: update openapi files 2025-12-01 21:39:09 +08:00
Calcium-Ion 34dfeece0d 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 e19e9ad2fa feat(gemini): implement markdown image handling in text processing 2025-12-01 17:54:41 +08:00
CaIon b1f2fac898 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 5d05cd9d32 feat(gemini): add validation and conversion for imageConfig parameters in extra_body 2025-11-30 19:31:08 +08:00
CaIon d4fbe1cee9 fix(vertex): ensure sampleCount is a positive integer and update OtherRatios 2025-11-30 19:05:33 +08:00
Calcium-Ion c2d604b4d6 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 c074ed2eb5 Revert "OAI生图接口支持gemini 3 pro image preview" 2025-11-30 18:49:18 +08:00
Calcium-Ion 8c5c944fd6 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 d33e4c8d35 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 907ab6ee06 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 2618a8ed59 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 4c8287411e Revert "fix: nano-banana not compatible imageSize" 2025-11-30 18:46:10 +08:00
Seefs b827d1f778 Revert "Gemini Image系列支持图像编辑" 2025-11-30 18:45:54 +08:00
Seefs b18704c320 Revert "Gemini Veo3.1[AI Studio]增加图生视频支持" 2025-11-30 18:45:37 +08:00
Seefs 2fb1fa08d2 Revert "fix: gemini image correct generationConfig" 2025-11-30 18:45:23 +08:00
Seefs 10543789d7 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 e1223e579d 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 d5777de96b fix: edit vertex key type (#2311) 2025-11-30 16:21:49 +08:00
Seefs 0ba6004ffc 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 ec811724e0 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 21a0b5b021 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 cf6c77480c 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 594075bfdc fix: nano banana pro 4k(StreamScannerMaxBufferMB env) 2025-11-30 16:08:25 +08:00
Seefs e3fbbb91d0 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 3901781068 fix: nano-banana not compatible imageSize 2025-11-29 00:58:25 +08:00
jason.mei f928e9d5da fix(aws): simplify HTTP status code extraction from AWS errors 2025-11-28 18:03:53 +08:00
jason.mei a925b8ecb9 fix(aws): extract HTTP status code from AWS SDK errors 2025-11-28 17:43:37 +08:00
IcedTangerine 88bdf94807 Merge pull request #2324 from feitianbubu/pr/video-download-oai
feat: 视频下载和界面预览统一使用OAI标准接口
2025-11-28 17:03:39 +08:00
feitianbubu e898e6aff3 feat: all video preview use videos/:id/content 2025-11-28 13:11:31 +08:00
IcedTangerine a1829c915e Merge pull request #2321 from feitianbubu/pr/gemini-image-edit
Gemini Image系列支持图像编辑
2025-11-27 18:04:50 +08:00
IcedTangerine 4960ffb05e Fix defer placement for image file closure 2025-11-27 18:01:34 +08:00
IcedTangerine 365133ccf2 Fix error message formatting in relay_utils.go 2025-11-27 17:59:38 +08:00
IcedTangerine 5ea40e1db8 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 5a8fafc69f 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 fb55d56089 feat: gemini image support edit 2025-11-27 16:04:04 +08:00
feitianbubu 70d74d74a4 feat: gemini video veo3.1 add task fail check 2025-11-26 21:56:14 +08:00
feitianbubu ff19082c84 feat: gemini video veo3.1 add i2v 2025-11-26 21:56:13 +08:00
ImogeneOctaviap794 c2053a7f43 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 08952ffd76 Merge pull request #2309 from feitianbubu/pr/fix-gemini-ImageConfig
fix: gemini image correct generationConfig
2025-11-26 18:46:06 +08:00
Seefs a3796b90d1 fix: edit vertex key type 2025-11-26 18:12:36 +08:00
feitianbubu 156229ce92 fix: gemini image correct generationConfig 2025-11-26 15:54:11 +08:00
IcedTangerine 67fd3ad94f 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 2d6f8fb58f feat: gemini-3-pro-image-preview add extra param 2025-11-26 12:03:24 +08:00
feitianbubu 796d62c605 feat: support gemini-3-pro-image-preview via images/generations 2025-11-26 12:03:24 +08:00
Seefs 8daea6e7ee fix: claude request missing field 2025-11-26 02:06:25 +08:00
Seefs d98e7b3dbf Merge pull request #2296 from seefs001/fix/adapter-missing
fix: volcengine claude DoResponse
2025-11-25 16:45:14 +08:00
Calcium-Ion f30fe61a6f Merge pull request #2295 from seefs001/fix/adapter-missing
fix: volcengine claude DoResponse
2025-11-25 15:54:39 +08:00
Seefs 32ccc63439 fix: volcengine claude DoResponse 2025-11-25 15:45:31 +08:00
Calcium-Ion 024593a0d3 Merge pull request #2294 from seefs001/fix/adapter-missing
fix: volcengine && baidu claude adapter
2025-11-25 15:31:26 +08:00
Seefs 7351006662 fix: volcengine && baidu claude adapter 2025-11-25 15:06:03 +08:00
Seefs 64df1fa04c 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 7231357323 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 766b6bc795 feat: add claude-opus-4-5-20251101 2025-11-25 10:53:01 +08:00
Papersnake 113ca72ae9 feat: add claude-opus-4-5-20251101 ratio 2025-11-25 10:49:34 +08:00
Seefs a1050f697f 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 2e7651674c 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 7645300f96 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 ff31f7df1a Merge pull request #2278 from seefs001/fix/release-version
fix: release workflow show version
2025-11-23 23:51:32 +08:00
Calcium-Ion 6a1cb9ebd5 Merge pull request #2277 from seefs001/feature/model_list_fetch
feat: 二次确认添加重定向前模型 && 重定向后模式视为已有模型
2025-11-23 23:51:11 +08:00
Calcium-Ion e18e45bee7 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 800c3f2ba4 Merge pull request #2274 from seefs001/feature/thinking_level
feat: gemini thinking_level && snake params
2025-11-23 23:50:50 +08:00
CaIon 89709dfa74 feat: Set ContextKeyLocalCountTokens in NativeGeminiEmbeddingHandler for token tracking 2025-11-23 23:50:04 +08:00
CaIon ed3858ada7 refactor: Deprecate HARM_CATEGORY_CIVIC_INTEGRITY in safety settings 2025-11-23 23:45:48 +08:00
Seefs e39ee1342f Merge pull request #1706 from StageDog/feat/discord_oauth
feat: 关联 discord 账号
2025-11-23 18:54:55 +08:00
StageDog 18bf8aad60 fix: IsDiscordIdAlreadyTaken 应该检查软删除记录 2025-11-23 00:07:34 +08:00
Seefs de4b52d320 fix: release workflow show version 2025-11-22 20:06:13 +08:00
Seefs 3fc9182dd4 feat: 重定向后的模型视为已有的模型,附带特殊提示 2025-11-22 19:34:36 +08:00
Seefs d6939d2f64 feat: 二次确认添加重定向前模型 2025-11-22 19:23:27 +08:00
StageDog cf8d4e874d feat: 针对 discord 登录配置使用新版设置方案 2025-11-22 19:06:53 +08:00
StageDog 352801cd35 feat: 关联 discord 账号 2025-11-22 18:38:24 +08:00
Seefs dbdcb14e7a feat: embedding param override && internal params 2025-11-22 18:27:17 +08:00
CaIon 5a037f2acd feat: Add CountToken configuration and update token counting logic 2025-11-22 17:15:34 +08:00
Seefs 5901f8ccf1 feat: gemini thinking_level && snake params 2025-11-22 16:30:46 +08:00
CaIon 4dbbf06adc fix: Update GET_MEDIA_TOKEN_NOT_STREAM default value to false 2025-11-22 16:23:37 +08:00
Seefs b969743482 Merge pull request #2268 from chokiproai/main
feat: Add Vietnamese language support
2025-11-22 00:47:32 +08:00
Seefs cf27747518 Merge pull request #2224 from jarvis-u/main
fix: 错误解析responses api中的input字段
2025-11-22 00:31:24 +08:00
CaIon c8bbf7bed8 feat: Add ContextKeyLocalCountTokens and update ResponseText2Usage to use context in multiple channels 2025-11-21 18:17:01 +08:00
Chokiproai eef08e5b3e add Vietnamese language support 2025-11-21 10:40:14 +07:00
borx e62ccd3431 fix: cast size to int64 before comparing with MaxUint32 2025-11-20 23:57:30 +08:00
Calcium-Ion 9b6b8d0eb8 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 b8d5b22388 fix: When retrieving the model list with multiple keys, select the first enabled one. 2025-11-20 18:02:17 +08:00
Calcium-Ion ab02dc2616 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 4ac4849ab6 feat: Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format 2025-11-20 15:54:33 +08:00
Calcium-Ion 266e272e74 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 529369298f 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] bb74f3dde7 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 5acab8fb68 fix: claude cache price render 2025-11-20 00:56:09 +08:00
Seefs b9842b73d2 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 edf76d3f2a feat: channel by tag omit key 2025-11-19 19:25:27 +08:00
Calcium-Ion ea9be41fc9 Merge pull request #2243 from seefs001/feature/gemini-3
feat: gemini-3-pro
2025-11-19 14:52:00 +08:00
Calcium-Ion ee513f2195 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 d6244daebb fix: optimized the GitHub login copy and timeout. (#2244) 2025-11-19 14:50:56 +08:00
Seefs b0f8ef5764 fix: optimized the GitHub login copy and timeout. 2025-11-19 14:34:30 +08:00
Seefs a74ab98cea feat: MediaResolution && VideoMetadata 2025-11-19 13:42:32 +08:00
Seefs 89a6303032 feat: gemini-3-pro 2025-11-19 01:46:51 +08:00
CaIon 0d16a06c3a fix: aws 2025-11-18 16:56:46 +08:00
Seefs 70b614258d Merge pull request #2239 from QAbot-zh/modelCategories-update
update model categories' match rules
2025-11-17 16:08:04 +08:00
undefinedcodezhong 8f1585e7e3 update model categories' match rules 2025-11-17 14:54:12 +08:00
Seefs 1381666586 Merge pull request #2238 from seefs001/feature/doubao-coding-plan
feat: support doubao coding plan
2025-11-16 23:49:35 +08:00
Calcium-Ion 055fde757c Merge pull request #2237 from seefs001/feature/linux-do-settings
feat: support configuring the linuxdo endpoint via environment variables
2025-11-16 15:43:47 +08:00
Seefs 2a7260208b feat: linuxdo oauth endpoint -> environment 2025-11-16 14:50:59 +08:00
CaIon 2539464dcb feat(adaptor): Add support for Claude-specific headers in SetupRequestHeader 2025-11-16 14:28:41 +08:00
Calcium-Ion 0106d2702a Merge pull request #2236 from seefs001/feature/vertex-k2
feat: support vertex open source models
2025-11-16 14:24:15 +08:00
Seefs f4c0972dc9 feature: support vertex open source models 2025-11-16 14:23:11 +08:00
Calcium-Ion 716e41071f Merge pull request #2235 from seefs001/fix/boundary-parser-error
fix: boundary parser error (error parsing multipart NextPart: bufio: buffer full)
2025-11-16 14:12:46 +08:00
Seefs 99055bab25 fix: boundary parser error (error parsing multipart form: multipart: NextPart: bufio: buffer full) 2025-11-16 14:09:10 +08:00
dependabot[bot] 4d0567f4d0 chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in /electron
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-15 20:14:28 +00:00
Seefs 9009a572fe feat: support gpt-5.1 prompt_cache_retention (#2228) 2025-11-15 13:32:24 +08:00
Seefs 72402f0d33 Merge pull request #2207 from QAbot-zh/reasoning
support reasoning field for playground
2025-11-15 13:26:57 +08:00
IcedTangerine 989ef56151 Merge pull request #2227 from feitianbubu/pr/add-wan2.5-i2i-preview
增加wan2.5-i2i-preview图生图支持
2025-11-15 12:43:09 +08:00
feitianbubu 010638a884 feat: add wan2.5-i2i-preview support 2025-11-14 20:30:18 +08:00
Seefs f8a69ec8f3 Merge pull request #2226 from QuantumNous/omit-anthropic_beta-empty
fix(relay/channel/aws): 修复AnthropicBeta字段的omitempty处理
2025-11-14 16:55:20 +08:00
creamlike1024 11426aa83a fix(relay/channel/aws): 修复AnthropicBeta字段的omitempty处理 2025-11-14 15:54:12 +08:00
IcedTangerine 59a74909d3 Merge pull request #2225 from feitianbubu/pr/add-hailuo-video
新增MiniMax海螺视频模型支持
2025-11-14 14:48:59 +08:00
CaIon 7827828abf fix(adaptor): Add '-none' suffix to effortSuffixes for model parsing 2025-11-14 14:04:34 +08:00
CaIon d2c268dd51 feat(aws): Add support for anthropic-beta header in AwsClaudeRequest 2025-11-14 12:01:20 +08:00
feitianbubu aa2f027dea feat: add hailuo i2v fl2v r2v 2025-11-14 11:55:43 +08:00
feitianbubu c297f42c1a feat: get hailuo video url 2025-11-14 11:55:43 +08:00
feitianbubu b242d4c754 feat: add MiniMax Hailuo video 2025-11-14 11:55:43 +08:00
wujiacheng 733e3870e2 fix: 错误解析responses api中的input字段 2025-11-14 09:58:39 +08:00
Calcium-Ion 3138483319 Merge pull request #2222 from xyfacai/main
fix: 未设置价格模型不会被拉取,除非设置自用模式
2025-11-13 19:00:21 +08:00
Xyfacai a4bfec164e fix: 未设置价格模型不会被拉取,除非设置自用模式 2025-11-13 18:44:18 +08:00
CaIon a05a815f99 fix(claude): Prevent duplicate header values in WriteHeaders method 2025-11-13 16:49:40 +08:00
CaIon b6e5ec8c49 refactor(adaptor): Comment out enable_thinking logic for clarity and future adjustments 2025-11-12 17:24:25 +08:00
Calcium-Ion 0c79519a5c Merge pull request #2209 from seefs001/fix/get-channel-key
fix GetChannelKey AdminAuth -> RootAuth
2025-11-11 21:46:13 +08:00
Seefs 5c971b1dff fix GetChannelKey AdminAuth -> RootAuth 2025-11-11 21:44:44 +08:00
Calcium-Ion ae74423cd2 Merge pull request #2208 from seefs001/fix/get-channel-key
fix GetChannelKey AdminAuth -> RootAuth
2025-11-11 21:38:55 +08:00
Seefs 31d0e6973c fix GetChannelKey AdminAuth -> RootAuth 2025-11-11 21:37:53 +08:00
Q.A.zh d0449c9967 support reasoning field 2025-11-11 13:00:20 +00:00
Calcium-Ion ae8b09d45f Merge pull request #2190 from Sh1n3zZ/support-replicate-channel
feat: replicate channel flux model
2025-11-10 17:22:31 +08:00
IcedTangerine 18b0360072 Merge pull request #2204 from feitianbubu/pr/vidu-q2-reference
修复viduq2不支持参考生视频的问题
2025-11-10 17:09:41 +08:00
feitianbubu 29c719279a feat: vidu reference2video only viduq2 2025-11-10 16:37:27 +08:00
feitianbubu d0f13fab1c feat: vidu specify reference2video via metadata action 2025-11-10 16:37:26 +08:00
CaIon 48f767f8c3 同步多语言README文档
- 更新中文README.md中的语言链接
- 完全重写英文README.en.md,包含所有详细功能说明
- 完全重写法文README.fr.md,确保内容一致性
- 完全重写日文README.ja.md,提供完整的项目说明

所有语言版本现在具有:
- 相同的结构和格式
- 一致的语言导航
- 完整的功能特性和部署指南
- 统一的环境变量配置说明
2025-11-09 14:01:42 +08:00
CaIon 6fc9b81941 chore: Update README.md for improved structure and clarity, including new sections for partners, acknowledgments, and deployment instructions 2025-11-08 22:32:39 +08:00
Sh1n3zZ af671d34c3 feat: replicate channel flux model 2025-11-08 01:24:45 +08:00
Seefs fd55ba7f2c feat: ShouldPreserveThinkingSuffix (#2189) 2025-11-07 17:43:33 +08:00
Seefs 2b469c6323 Merge pull request #2188 from QuantumNous/fix-multikey-autodisable
fix(channel): 当没有可用密钥时返回错误而不是第一个密钥
2025-11-07 17:41:39 +08:00
Seefs 6198b0eae8 Merge pull request #2156 from feitianbubu/pr/fix-tag-whitespace
fix: tag splitting by whitespace
2025-11-07 17:40:02 +08:00
creamlike1024 2fa5deb916 fix(channel): 当没有可用密钥时返回错误而不是第一个密钥 2025-11-07 16:27:54 +08:00
feitianbubu 92f7cdeb40 fix: update tag normalization regex 2025-11-06 23:24:37 +08:00
CaIon 28295e0168 feat: restrict automatic channel testing to master node only 2025-11-06 21:12:59 +08:00
Seefs c390734cef feat: EditTagModal header && param (#2159) 2025-11-06 20:18:45 +08:00
Seefs de2501241f add custom tool (#2157) 2025-11-06 20:18:25 +08:00
Seefs 316ba157c9 fix playground (#2153) 2025-11-06 20:18:00 +08:00
CaIon c1af01c3f2 feat: add TASK_PRICE_PATCH environment variable for per-task billing configuration 2025-11-06 20:06:02 +08:00
Seefs 06260228d4 Merge pull request #2168 from feitianbubu/pr/fix-jimeng-1080p-image
fix: trim suffix p for jimeng image model
2025-11-06 19:54:02 +08:00
Seefs 0ce4cd449d Merge pull request #2178 from LeonDevLifeLog/main
feat: add environment variable switch for critical rate limit
2025-11-06 19:48:28 +08:00
Seefs 7d3a63eee0 Merge pull request #2182 from zhaolion/main
feat:  EditTokenModal 中针对用户创建的 token 默认无限额度
2025-11-06 19:41:27 +08:00
zhaolion 2beda32a05 feat: EditTokenModal 中针对用户创建的 token 默认无限额度 2025-11-06 19:36:23 +08:00
Leon 71c26a5843 feat: add environment variable switch for critical rate limit 2025-11-06 15:23:34 +08:00
CaIon 942a4311ab feat: enhance Ali video request processing with resolution mapping and size validation 2025-11-05 16:02:39 +08:00
CaIon 64f98c4348 fix: logger 2025-11-05 14:49:55 +08:00
feitianbubu 894ddc06ab fix: trim suffix p for jimeng image model 2025-11-04 20:21:33 +08:00
IcedTangerine ea4bd73870 Merge pull request #2167 from feitianbubu/pr/fix-jimeng-v30-pro
修复即梦v30-pro视频生成失败问题
2025-11-04 18:37:44 +08:00
feitianbubu 9893287f22 feat: jimeng_v30_pro only jimeng_ti2v_v30_pro model 2025-11-04 18:29:53 +08:00
IcedTangerine 961af95ec4 fix: openai 音频模型流模式未正确计费 (#2160) 2025-11-04 01:43:04 +08:00
Seefs e1b9317531 feat: claude 1h cache (#2155)
* feat: claude 1h cache

* feat: claude 1h cache

* fix price
2025-11-04 00:20:50 +08:00
feitianbubu a1f9a1c872 fix: tag splitting by whitespace 2025-11-03 18:48:49 +08:00
CaIon 564e45ce82 refactor: comment out image file validation for qwen edit in Ali image processing 2025-11-01 14:31:32 +08:00
CaIon cfb39a85e3 fix: improve error handling and validation in Ali video request conversion 2025-10-31 22:39:35 +08:00
CaIon e705cff403 feat: enhance Ali video request handling and validation 2025-10-31 22:26:56 +08:00
Seefs a98e207ef7 feat: add ali wan video (#2141)
* feat: add ali wan video

* refactor: use same UnmarshalBodyReusable

* feat: enhance request body metadata

* feat: opt wan convertToOpenAIVideo

* feat: add wan support other param via json metadata

* refactor: remove unused code

* fix ali

---------

Co-authored-by: feitianbubu <feitianbubu@qq.com>
2025-10-31 16:51:05 +08:00
Seefs 36b712437d fix veo3 (#2140) 2025-10-31 15:29:17 +08:00
Seefs 26a21bd209 Merge pull request #2075 from feitianbubu/pr/add-gemini-veo-video
Gemini渠道支持veo视频生成
2025-10-31 13:57:59 +08:00
Seefs 19c40c5ae4 Merge pull request #2124 from feitianbubu/pr/fix-topup-link
fix: correct topUp link
2025-10-31 13:43:23 +08:00
CaIon ba949ac5af fix(override): handle null value comparison in JSON equality check 2025-10-30 23:28:14 +08:00
CaIon 758cd2c15f refactor(group): update user group handling to utilize userUsableGroups directly and add GetUserGroupRatio function 2025-10-30 21:16:42 +08:00
wzxjohn 24cdd6bbf1 fix: typo 2025-10-30 14:21:46 +08:00
feitianbubu 2c84c79fbf fix: stripe cancelURL topUp link 2025-10-30 13:41:52 +08:00
wzxjohn da97c8ecc3 fix(web): index page does not have analytic 2025-10-30 12:17:51 +08:00
CaIon 9db9b6a8ce feat(i18n): add translations for "The Unified" and "LLM API Gateway" in English, French, Japanese, and Russian 2025-10-30 00:14:12 +08:00
Calcium-Ion 1ab86bc9ab feat(language): add Japanese language support to LanguageSelector and i18n configuration (#2131) 2025-10-29 23:58:10 +08:00
CaIon 5d12755798 refactor(relay): enhance error logging and improve multipart form handling in audio requests #2127 2025-10-29 23:33:55 +08:00
CaIon 602a92849c fix(audio): add support for .opus files in audio duration calculation 2025-10-29 22:47:39 +08:00
CaIon bbdc57c18c refactor(channel_cache): improve channel selection logic by handling zero total weight and removing unnecessary variable 2025-10-29 21:49:06 +08:00
Seefs 77da962823 Merge pull request #2126 from QuantumNous/fix-randomWeight-panic
fix: 当totalWeight小于等于0时设置为1选择第一个渠道
2025-10-29 21:02:18 +09:00
creamlike1024 5f8c2ea962 fix(channel_cache): 修复总权重小于等于0时的渠道选择逻辑 2025-10-29 19:59:39 +08:00
creamlike1024 93edde591b fix: 当totalWeight小于等于0时设置为1选择第一个渠道 2025-10-29 19:41:45 +08:00
Seefs b26333b2cd Merge pull request #2121 from QuantumNous/feat/special_group
feat: add special user usable group setting
2025-10-29 18:54:51 +09:00
feitianbubu ee75f0919a fix: correct topUp link 2025-10-29 16:55:29 +08:00
CaIon e7012e7e0a fix: reduce auto test channel sleep duration to 1 minute 2025-10-28 23:35:20 +08:00
CaIon 6aec088693 feat: add special user usable group setting 2025-10-28 23:25:43 +08:00
Seefs 25de94e292 fix: test channel frequency (#2119) 2025-10-28 23:23:24 +08:00
Seefs 7b838d642a fix: creem setting jsx -> js (#2120) 2025-10-28 23:22:07 +08:00
Seefs 08cdb9bace Merge pull request #2118 from comeback01/fix/go-import-paths
fix(go): Correct Go module import paths
2025-10-28 23:19:52 +09:00
comeback01 30e0f8090e fix(go): correct module import paths 2025-10-28 13:23:10 +01:00
Seefs 4ed5f1abb3 Merge pull request #1823 from littlewrite/feat_subscribe_sp1
新增 creem 支付
2025-10-28 18:37:32 +09:00
Seefs 2c74048f72 Merge pull request #2102 from Sh1n3zZ/feat-sora-vertex-adaptor
feat: vertex veo sora-compatible video output
2025-10-28 18:36:43 +09:00
Seefs 68afbba355 Merge pull request #2092 from feitianbubu/pr/doubao-image-edit
feat: add image handling to image request for form-data
2025-10-28 18:33:22 +09:00
Seefs 9e98f04596 Merge pull request #2084 from QuantumNous/update-gitignore
chore: Ignore .zed and debug binaries in .gitignore
2025-10-28 18:32:47 +09:00
Seefs 576775cbe1 Merge pull request #2098 from HynoR/chore/c1
chore: Update AWS claude 4.5 haiku model's information
2025-10-28 18:32:23 +09:00
Seefs 32beb5796c Merge pull request #2107 from wuhan005/wh/aws-client-support-proxy
feat: aws client supports proxy settings
2025-10-28 18:31:56 +09:00
Seefs 347020f93e Merge pull request #2111 from feitianbubu/pr/top-up-show-correct-symbol
feat: topUp show correct symbol
2025-10-28 18:31:32 +09:00
Seefs cc92ef6bf8 Merge pull request #2109 from LainCyberia/feature/i18n-ja
feat(i-18n): Add Japanese localization
2025-10-28 18:31:06 +09:00
CaIon a00c6393c5 feat: implement audio duration retrieval without ffmpeg dependencies 2025-10-28 15:50:45 +08:00
feitianbubu 0fafcdca69 feat: topUp show correct symbol 2025-10-27 17:45:53 +08:00
iwu ea6dc0c338 feat: aws client supports proxy settings
Signed-off-by: iwu <iwu@tencent.com>
2025-10-27 15:00:20 +08:00
LainCyberia 6cd8436396 feat(i-18n): Add Japanese localization 2025-10-27 14:10:14 +08:00
CaIon a25c11df2a feat: add support for Submodel channel type in relay info 2025-10-25 22:10:26 +08:00
IcedTangerine 5125606f25 Merge pull request #2103 from feitianbubu/pr/doubao-image-watermark
fix: correct bool value for watermark
2025-10-25 11:44:34 +08:00
feitianbubu 180a9b9187 fix: correct bool value for watermark 2025-10-25 11:26:03 +08:00
Sh1n3zZ 0dee868589 feat: vertex veo sora-compatible video output 2025-10-25 02:00:35 +08:00
HynoR 87d72a50c9 chore: update aws claude 4.5 haiku model's information 2025-10-24 14:21:17 +08:00
feitianbubu e0cb9c1f66 feat: add image handling to image request for form-data 2025-10-23 23:24:37 +08:00
IcedTangerine 3d7d151cbd Merge pull request #2090 from feitianbubu/pr/doubao-image-edit
修复豆包图像编辑(图生图)功能
2025-10-23 22:39:18 +08:00
feitianbubu 86cd2802d5 fix: fail get moel by
multipart/form-data; boundary
2025-10-23 22:15:02 +08:00
feitianbubu fe9b092b0b feat: doubao-seedream support image edit 2025-10-23 21:19:33 +08:00
feitianbubu a4c46e999e feat: doubao-seedream-4-0-250828 image to image 2025-10-23 21:19:32 +08:00
IcedTangerine 01b3c35b70 Merge pull request #2087 from feitianbubu/pr/doubao-tts-stream
feat: doubao tts support streaming realtime audio
2025-10-22 17:44:01 +08:00
feitianbubu fd6cd838f7 refactor: clean up doubao tts code 2025-10-22 17:06:13 +08:00
IcedTangerine 51ed6fa0e6 Merge pull request #2086 from feitianbubu/pr/openai-tts-stream
feat: openai tts support streaming realtime audio
2025-10-22 14:07:25 +08:00
feitianbubu 828bb17d2c feat: doubao tts add is stream check 2025-10-22 13:39:16 +08:00
feitianbubu 23c22708f2 feat: doubao tts support streaming realtime audio 2025-10-22 13:39:16 +08:00
feitianbubu f617ff8741 feat: openai tts support streaming realtime audio 2025-10-22 13:33:01 +08:00
creamlike1024 22ec4601b2 chore: Ignore .zed and debug binaries in .gitignore 2025-10-21 16:40:22 +08:00
Seefs bba0cbe6f1 Merge pull request #2070 from QuantumNous/ali-channel-support-stream-options
Ali channel support stream options
2025-10-20 23:24:33 +08:00
Little Write dca9bac4a4 Merge branch 'main' into feat_subscribe_sp1 2025-10-20 22:36:33 +08:00
IcedTangerine c8c207b464 Merge pull request #2081 from feitianbubu/pr/add-miniMax-tts
增加MiniMax语音合成TTS支持
2025-10-20 17:48:35 +08:00
IcedTangerine c74d89c47a chore: Comment out debug log in adaptor.go
Comment out the debug log for MiniMax TTS Request.
2025-10-20 17:48:08 +08:00
feitianbubu d52505cac1 feat: opt minimax tts req struct 2025-10-20 16:26:50 +08:00
feitianbubu a2d34b9e47 feat: add minimax api adaptor 2025-10-20 16:26:50 +08:00
feitianbubu 0a8e54de20 feat: add minimax tts 2025-10-20 16:26:50 +08:00
IcedTangerine 8a9f4ac1aa Add type assertion for task_request in adaptor.go 2025-10-18 23:07:50 +08:00
IcedTangerine 3b14ecedaf Fix error logging for channel retrieval failure 2025-10-18 23:06:25 +08:00
feitianbubu 2f5d43fa61 feat: gemini veo req to struct 2025-10-18 21:55:25 +08:00
feitianbubu 18615abb55 feat: veo video url use proxy download 2025-10-18 21:55:25 +08:00
feitianbubu 8dfc987a87 feat: add gemini veo3.1 2025-10-18 21:55:25 +08:00
creamlike1024 2a2dd438db refactor: remove unused functions and imports from ali text handler 2025-10-18 17:00:28 +08:00
creamlike1024 d493fffa3f feat: add support for Ali channel in streamSupportedChannels 2025-10-18 17:00:08 +08:00
IcedTangerine 8ac801261d Merge pull request #2068 from feitianbubu/pr/doubao-speech-emotion
豆包语音2.0音色支持情感,情绪,音量
2025-10-18 14:30:17 +08:00
Seefs 0d62db7ce7 Merge pull request #2065 from somnifex/main
fix: handle JSON parsing for thinking content in ollama stream
2025-10-18 13:02:56 +08:00
Seefs 69598b8457 Merge pull request #2061 from QuantumNous/fix-gemini-batch-embedding-token-count
fix: gemini batch embedding token not counted
2025-10-18 12:54:44 +08:00
feitianbubu 13362887dd fix: doubao audio speedRadio to speed 2025-10-18 01:48:36 +08:00
feitianbubu 412b935a4d feat: AudioRequest add metadata support custom params 2025-10-18 01:48:36 +08:00
feitianbubu 6de10991d7 feat: sync latest openai speech struct
https://platform.openai.com/docs/api-reference/audio/createSpeech
2025-10-18 01:48:36 +08:00
IcedTangerine 616a0261dc Merge pull request #2067 from feitianbubu/pr/add-doubao-audio
新增支持豆包语音合成2.0功能
2025-10-18 00:14:11 +08:00
IcedTangerine 319680d052 Fix error message for invalid API key format 2025-10-18 00:13:28 +08:00
feitianbubu 4a6676ccd5 feat: add doubao audio token input prompt 2025-10-17 22:06:46 +08:00
feitianbubu 0952631fa2 feat: add doubao tts usage token 2025-10-17 22:06:45 +08:00
feitianbubu 1be4e12ca0 feat: switch to official TTS only when baseUrl is Volcano's official URL 2025-10-17 22:06:45 +08:00
feitianbubu f052f8ba22 feat: add doubao audio tts 2025-10-17 22:06:45 +08:00
somnifex e4a2904c56 fix: handle JSON parsing for thinking content in ollama stream and chat handlers 2025-10-17 18:35:08 +08:00
creamlike1024 400014d1db fix: gemini batch embedding token not counted 2025-10-17 15:51:04 +08:00
CaIon 2df75d14a0 feat: support OpenAI channel type in sora relay adaptor 2025-10-17 13:53:15 +08:00
CaIon 8ff87ae6c2 feat: add temporary TASK_PRICE_PATCH configuration to environment variables 2025-10-16 21:59:21 +08:00
CaIon 8511ea0fff fix: prevent refund on video task update error 2025-10-16 12:46:07 +08:00
CaIon b70b4597ce fix: prevent duplicate refunds on task failure #2050 2025-10-16 12:38:21 +08:00
CaIon 6df44134ab refactor: rename AWS model ID and region prefix functions for clarity 2025-10-16 12:10:55 +08:00
CaIon 5a10552d69 fix: gemini embedding 2025-10-15 21:48:36 +08:00
CaIon f91522b51e CI: ignore pre-release and alpha tags in electron build workflow 2025-10-15 19:56:07 +08:00
CaIon 812ab67780 refactor: replace iota with explicit values for log type constants 2025-10-15 19:54:13 +08:00
CaIon b9c2089d37 fix 2025-10-15 19:46:06 +08:00
CaIon 48cadc95f3 feat: 修复aws渠道-thinking后缀不生效的问题 2025-10-15 18:49:27 +08:00
CaIon 1fdb54651b feat: 临时兼容aws使用链接媒体 2025-10-15 18:21:19 +08:00
CaIon 5bee54a11d refactor: update AWS key format in EditChannelModal for consistency 2025-10-15 17:38:21 +08:00
CaIon 59caea8f34 feat: support aws bedrock api-keys-use 2025-10-15 17:29:10 +08:00
CaIon 31e09b960f refactor: aws 2025-10-15 16:44:33 +08:00
CaIon 5069482d22 refactor: update ConvertToOpenAIVideo method to return byte array and improve error handling 2025-10-14 23:03:17 +08:00
CaIon 6e08c5dc67 docs: update README for project cloning and Docker Compose instructions 2025-10-14 17:51:33 +08:00
CaIon ffb60c9749 feat: add Umami and Google Analytics integration 2025-10-14 14:19:49 +08:00
CaIon 9ab5df9398 fix: remove redundant error message details for channel retrieval failures 2025-10-14 13:53:33 +08:00
Seefs de1590f5ee Merge pull request #2038 from seefs001/feature/endpoint_type_log
feat: endpoint type log
2025-10-14 13:06:54 +08:00
Seefs c62daee6f3 Merge pull request #2036 from etnAtker/siliconflow-images-generations
feat: 添加SiliconFlow图像生成接口自动转换支持
2025-10-14 13:05:04 +08:00
Seefs 31f565b87f clean 2025-10-14 00:16:08 +08:00
Seefs 10aa680890 feat: endpoint type log 2025-10-14 00:06:52 +08:00
Seefs b3fc7255ee feat: endpoint type log 2025-10-13 22:44:54 +08:00
Seefs 9718568179 feat: endpoint type log 2025-10-13 22:25:39 +08:00
CaIon 7391b15b4b fix: remove redundant error handling for empty Gemini API response 2025-10-13 21:58:50 +08:00
Seefs ae8b8b344b Merge pull request #2035 from Inblac/devpre
fix(convert): 修复 OpenAI 转 Claude 流时 thinking 块的格式问题
2025-10-13 20:58:43 +08:00
etnAtker fc2486d878 fix: 修复PR的潜在问题
1. 解析ImageRequest的Extra时,处理err
2. DoResponse方法添加RelayModeImagesGenerations(fallthrough)
2025-10-13 20:20:08 +08:00
etnAtker 933e42e5df feat: 添加SiliconFlow图像生成接口自动转换支持
1. 将对SiliconFlow渠道的RelayModeImagesGenerations请求,转发至v1/images/generations端点。
2. SiliconFlow图像生成接口额外参数适配。
2025-10-13 20:06:33 +08:00
yanggh b6f352735e fix(convert): 修复 OpenAI 转 Claude 流时 thinking 块的格式问题
将 `ClaudeMediaMessage.Thinking` 的类型从 `string` 修改为 `*string`,以解决 `omitempty` 导致 `"thinking": ""` 字段在 JSON 序列化时被忽略的问题。

同时更新了 `service/convert.go` 和 `relay/channel/claude/relay-claude.go` 中的相关逻辑,以兼容新的指针类型,确保生成的 Claude 事件流符合官方规范。
2025-10-13 19:32:17 +08:00
CaIon f6bf150d21 fix: improve error handling for email sending failures 2025-10-13 19:21:46 +08:00
CaIon 5e1d7ab3b9 feat: add support for Sora channel type and OpenAI video endpoint 2025-10-13 19:21:45 +08:00
skynono 7c543a9e19 feat: jimeng images base64 limit (#2032) 2025-10-13 15:17:23 +08:00
Xyfacai c6175c0b46 fix: 修复视频任务不同分组可能导致补回额度计算错误 (#2030) 2025-10-13 15:17:06 +08:00
Seefs 0dfd8f2743 Merge pull request #2029 from feitianbubu/pr/jimeng-support-oai-files
feat: jimeng use openai sdk input_reference i2v
2025-10-13 14:14:43 +08:00
Xyfacai 4f3030baf4 Merge pull request #2027 from xyfacai/refactor/openai-video
refactor: Openai video model 移动到 dto
2025-10-13 13:24:20 +08:00
feitianbubu 61f388bf84 feat: jimeng use openai sdk input_reference i2v 2025-10-13 13:06:03 +08:00
Xyfacai b7a07e1ef7 refactor: Openai video model 移动到 dto 2025-10-13 11:45:45 +08:00
CaIon 063926dd29 fix: update error messages for unsupported parameter names in Google extra body 2025-10-12 22:21:45 +08:00
CaIon 85fdcc78ac fix: 修复工作流重复创建release的问题 2025-10-12 15:40:22 +08:00
Seefs 04d441ef80 Merge pull request #2025 from feitianbubu/pr/protect-increase-quota
feat: Add pre-status protection for IncreaseUserQuota
2025-10-12 15:06:12 +08:00
feitianbubu b3e6145cb7 feat: Add pre-status protection for IncreaseUserQuota 2025-10-12 14:53:55 +08:00
Seefs b48584d30a Merge pull request #2024 from seefs001/fix/version
fix: empty version
2025-10-12 14:24:01 +08:00
Seefs 78eb532958 fix: empty version 2025-10-12 14:23:18 +08:00
CaIon d43751f262 feat: support free model setting 2025-10-12 13:31:03 +08:00
Seefs a781622dea Merge pull request #2023 from seefs001/fix/version
fix: version
2025-10-12 13:05:51 +08:00
Seefs 1e39b5ede1 fix: version 2025-10-12 13:05:13 +08:00
Seefs c64058c582 Merge pull request #2022 from seefs001/fix/ignore_ghcr
ignore ghcr
2025-10-12 12:40:18 +08:00
Seefs 1e6d34f00a ignore ghcr 2025-10-12 12:38:44 +08:00
CaIon 250f10fa67 fix: mask sensitive information in error messages and refine task retrieval query 2025-10-12 12:25:11 +08:00
Seefs 5feb22d8fb Merge pull request #2018 from feitianbubu/pr/add-jimeng-vidu-openai-sdk
即梦和vidu支持 OpenAI sdk生成和查询视频
2025-10-11 20:40:47 +08:00
feitianbubu 7f9309ee17 feat: add vidu use openai sdk 2025-10-11 17:11:44 +08:00
feitianbubu d733803c8a fix: kling create video via openai sdk 2025-10-11 17:11:44 +08:00
feitianbubu d724f25db4 refactor: add openaiVideo and method 2025-10-11 17:05:36 +08:00
feitianbubu 7c7313a937 feat: add jimeng use openai sdk 2025-10-11 17:05:33 +08:00
Seefs 3dc681008c Merge pull request #1991 from Sacode/i18n
feat: complete English, French and Russian translation and add i18n configuration
2025-10-11 15:56:55 +08:00
Seefs 5010f2d004 format: package name -> github.com/QuantumNous/new-api (#2017) 2025-10-11 15:30:09 +08:00
Seefs ff530f991b Merge pull request #2016 from seefs001/fix/ci-env
fix env GHCR_REPOSITORY
2025-10-11 14:19:42 +08:00
Seefs bed2863a13 fix env GHCR_REPOSITORY 2025-10-11 14:18:09 +08:00
Seefs bbc18e8331 feat: matrix ci (#2014) 2025-10-11 14:08:32 +08:00
Seefs 2606d16e4e Alpha CI (#2011) 2025-10-11 13:24:04 +08:00
Dmitriy Safonov 087248c2f9 feat: add French translation glossary for consistent terminology
Add comprehensive French translation glossary document to standardize key project terminology. The glossary includes translations for core concepts, model-related terms, user management, recharge & redemption, channel management, and security terms. This ensures consistency and accuracy in French translations across the project, with specific guidance on technical terms and contextual usage.
2025-10-11 08:16:13 +03:00
CaIon e6129bec57 feat: update Go version in CI configuration and add release workflow for multi-platform builds 2025-10-11 12:44:09 +08:00
CaIon 02c0495b24 fix: ensure error message is set when it is empty in error handling #1972 2025-10-11 12:12:41 +08:00
CaIon 59c5f42505 feat: add logging for model details and enhance action assignment in relay tasks 2025-10-11 11:56:44 +08:00
CaIon d540020c3d feat: enhance multipart validation with additional fields for model, seconds, and size 2025-10-11 11:33:59 +08:00
Calcium-Ion 32f748cd7b Merge pull request #2006 from xyfacai/feat/sora-price
feat: sora 增加参数校验与计费
2025-10-11 11:22:08 +08:00
Calcium-Ion 92c795483e Merge pull request #2004 from feitianbubu/pr/openai-sdk-kling
支持可灵使用openai sdk生成视频
2025-10-11 11:05:11 +08:00
Dmitriy Safonov 4724dc33da feat(i18n): add French pluralization support and complete translations
- Add pluralization rules for French locale using _one, _many, _other suffixes
- Complete missing French translations for web search, file search, and key count strings
- Add translations for import/export configuration functionality
- Fill in missing translations for UI elements like ID, IP, expand, and various status messages
- Improve French localization coverage for better user experience
2025-10-11 04:35:22 +03:00
Dmitriy Safonov 7ee4e9dea6 feat(i18n): enable pluralization for count-based translations
Enable i18next pluralization by setting disablePlurals to false and update multiple translation keys to use _one/_other suffixes for proper singular/plural handling. This improves localization accuracy for count-dependent strings like "X keys", "X models", and "X times".
2025-10-11 04:35:22 +03:00
Dmitriy Safonov f200b92800 feat(i18n): reorganize and expand ignored attributes list
Reordered the ignoredAttributes array in i18next.config.js alphabetically and added several new attributes to prevent unnecessary translation extraction. This improves the localization process by excluding more non-translatable properties like accept, align, autoComplete, clipRule, crossOrigin, and others.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov 54ad70de3b refactor(i18n): convert config from TypeScript to JavaScript
Converted i18next.config.ts to i18next.config.js and added AGPL license header. The change simplifies the build process by removing TypeScript compilation for this configuration file while maintaining the same functionality.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov 0c76a7217b feat: move i18next-cli to devDependencies
Relocated i18next-cli from dependencies to devDependencies as it's only needed for development tasks like translation management, not for runtime functionality.
2025-10-11 04:35:22 +03:00
Dmitriy Safonov bd02dca979 feat: add i18n configuration and expand translation glossary
Add comprehensive i18next configuration for internationalization support with Chinese, English, and French locales. Configure extraction settings and ignore patterns for React components. Expand translation glossary with security and billing terminology including Two-Factor Authentication, 2FA, and pricing multiplier terms.
2025-10-11 04:35:22 +03:00
feitianbubu 06ae3efa19 feat: add openai sdk create 2025-10-11 02:44:01 +08:00
feitianbubu c0859c056f feat: add openai sdk for kling 2025-10-11 02:43:56 +08:00
feitianbubu 69f497e350 refactor: openAI video use OpenAIVideoConverter 2025-10-11 02:43:43 +08:00
Seefs a92afd7a53 Merge pull request #2002 from qixing-jk/fix/dynamic-frequency-updates
fix(channel): handle dynamic frequency updates
2025-10-11 01:01:23 +08:00
anime ec4603942f fix(channel): move log statement after sleep in auto-test loop 2025-10-11 00:59:13 +08:00
anime bbb160510e fix(channel): handle dynamic frequency updates 2025-10-11 00:53:26 +08:00
anime cac0be2e66 fix(channel): handle dynamic frequency updates
- replace infinite sleep loop with time.Ticker to avoid goroutine leaks
- add immediate initial test execution before ticker starts
- implement frequency change detection and ticker recreation
- ensure proper ticker cleanup when loop exits or feature disabled
2025-10-11 00:34:15 +08:00
Xyfacai 09495c8ef6 feat: sora 增加参数校验与计费 2025-10-10 23:56:36 +08:00
Seefs e259a2f5fb Merge pull request #2000 from feitianbubu/pr/sora-retrieve-video-sdk
feat: support openAI sdk retrieve videos
2025-10-10 19:18:27 +08:00
feitianbubu 4e0a43a909 feat: support openAI sdk retrieve videos 2025-10-10 18:59:52 +08:00
CaIon b8f8329989 feat: add informational banner to proxy settings in SystemSetting.jsx 2025-10-10 16:44:41 +08:00
Seefs 452a7b0e95 Merge pull request #1401 from feitianbubu/pr/add-qwen-channel-auto-disabled
feat: add qwen channel auto disabled
2025-10-10 16:41:43 +08:00
Calcium-Ion 2b39ae557c Merge pull request #1998 from seefs001/feature/pplx-channel
feat: pplx channel
2025-10-10 16:33:27 +08:00
Seefs 1d32327d04 feat: pplx channel 2025-10-10 16:12:15 +08:00
Calcium-Ion fddf8583f8 Merge pull request #1997 from feitianbubu/pr/add-sora-fetch-task
支持Sora做为上游渠道
2025-10-10 16:10:58 +08:00
feitianbubu 7843d5b1a9 fix: sora fetch task route 2025-10-10 16:01:06 +08:00
Calcium-Ion 90a043cfde Merge pull request #1996 from seefs001/fix/remark-ignore
fix: channel remark ignore issue
2025-10-10 15:41:30 +08:00
Calcium-Ion 40f1acbb35 Merge pull request #1992 from seefs001/pr-upstream-1981
feat(web): add settings & pages of privacy policy & user agreement
2025-10-10 15:41:06 +08:00
Seefs a2d9634e70 fix: channel remark ignore issue 2025-10-10 15:40:00 +08:00
Calcium-Ion d326e88ab2 Merge commit from fork
feat: enhance HTTP client wit redirect handling and SSRF protection
2025-10-10 15:30:37 +08:00
Calcium-Ion 40aa549079 Merge pull request #1995 from seefs001/fix/test-channel-1993
fix: test model #1993
2025-10-10 15:26:18 +08:00
feitianbubu 94b68c2b63 feat: add sora video fetch task 2025-10-10 15:25:29 +08:00
CaIon bdb522021a feat: enhance HTTP client with custom redirect handling and SSRF protection 2025-10-10 15:13:41 +08:00
Seefs 015d488069 fix: test model #1993 2025-10-10 15:01:24 +08:00
Seefs 3011200592 Merge pull request #1994 from feitianbubu/pr/fix-video-model
fix: avoid get model consuming body
2025-10-10 14:28:16 +08:00
feitianbubu 0d0e4f5333 fix: avoid get model consuming body 2025-10-10 14:19:49 +08:00
Seefs 56ffbd57b9 fix: legal setting 2025-10-10 13:18:26 +08:00
キュビビイ b7725d7ae1 feat: add user agreement and privacy policy to login page 2025-10-09 22:21:56 +08:00
Seefs 3cc60f22ec Merge pull request #1988 from feitianbubu/pr/add-sora
新增Sora视频渠道
2025-10-09 15:37:31 +08:00
feitianbubu 405c6945eb feat: add sora video proxy video content 2025-10-09 15:00:02 +08:00
feitianbubu b1db126490 feat: add sora video retrieve task 2025-10-09 15:00:02 +08:00
feitianbubu 1d1759aaae feat: add sora video submit task 2025-10-09 15:00:02 +08:00
CaIon 23dce3b5b1 fix: update axios package version to 1.12.0 2025-10-09 14:21:49 +08:00
Calcium-Ion adff93c04c Merge pull request #1986 from QuantumNous/dependabot/npm_and_yarn/electron/electron-35.7.5
chore(deps-dev): bump electron from 28.3.3 to 35.7.5 in /electron
2025-10-09 14:18:42 +08:00
CaIon 0ecdf1b8ff fix: update jwt package import to v5 across multiple files 2025-10-09 14:17:49 +08:00
dependabot[bot] a0a10e63c8 chore(deps-dev): bump electron from 28.3.3 to 35.7.5 in /electron
Bumps [electron](https://github.com/electron/electron) from 28.3.3 to 35.7.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v28.3.3...v35.7.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 35.7.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-09 06:07:32 +00:00
Seefs d65a04ae9b Merge pull request #1982 from RedwindA/feat/zhipu_cache
fix(openai): account cached tokens for zhipu_v4 usage
2025-10-09 12:27:11 +08:00
Seefs 91e1d9a24a Merge pull request #1984 from RedwindA/feat/claude-fetchModels
feat: add GetClaudeAuthHeader function and update FetchUpstreamModels to support Anthropic channel type
2025-10-09 12:22:47 +08:00
Seefs 0a16be98a4 Merge pull request #1983 from Sh1n3zZ/feat-imagen-related
feat: gemini imagen quality value
2025-10-09 12:22:02 +08:00
RedwindA dcd0f4fb0d feat: add GetClaudeAuthHeader function and update FetchUpstreamModels to support Anthropic channel type 2025-10-09 02:43:19 +08:00
Sh1n3zZ 56a6c24722 feat: gemini imagen quality value 2025-10-09 01:16:04 +08:00
CaIon 5f19d212d0 feat: update tab label in index.jsx for clarity on pricing settings 2025-10-08 20:56:51 +08:00
RedwindA efdb1c5b46 fix(openai): account cached tokens for
zhipu_v4 usage
2025-10-08 16:52:49 +08:00
キュビビイ 0992f834da feat: componentize User Agreement and Privacy Policy display
Extracted the User Agreement and Privacy Policy presentation into a
reusable DocumentRenderer component (web/src/components/common/DocumentRenderer).
Unified rendering logic and i18n source for these documents, removed the
legacy contentDetector utility, and updated the related pages to use the
new component. Adjusted controller/backend (controller/misc.go) and locale
files to support the new rendering approach.

This improves reuse, maintainability, and future extensibility.
2025-10-08 11:12:49 +08:00
キュビビイ 00603520e9 feat(web): add settings & pages of privacy policy & user agreement 2025-10-08 10:43:47 +08:00
Seefs f005d2dc1a Merge pull request #1977 from QuantumNous/fix/bills
❤ fix(topup): prevent nil-pointer in Epay callback; reset page on search
2025-10-07 14:15:48 +08:00
Apple\Apple 33aeaf7b4d ❤ fix(topup): prevent nil-pointer in Epay callback; reset page on search
Add early return when Epay client is missing in controller/topup.go to avoid panic
Introduce handleKeywordChange in TopupHistoryModal.jsx to reset page to 1 when keyword updates
Wire input onChange to new handler; minor UX improvement to avoid empty results on pagination mismatch
2025-10-07 14:13:14 +08:00
Seefs e74d8b9985 Merge pull request #1976 from QuantumNous/feature/invoicing
 feat: Add topup billing history with admin manual completion
2025-10-07 12:08:33 +08:00
Apple\Apple c7ab5c9a67 🔎 feat(topup): add order number search for billing history (admin and user)
Enable searching topup records by trade_no across both admin-wide and user-only views.

Frontend
- TopupHistoryModal.jsx:
  - Add search input with prefix icon (IconSearch) to filter by order number
  - Send `keyword` query param to backend; works with both endpoints:
    - Admin: GET /api/user/topup?p=1&page_size=10&keyword=...
    - User:  GET /api/user/topup/self?p=1&page_size=10&keyword=...
  - Keep endpoint auto-switching based on role (isAdmin)
  - Minor UI polish: outlined admin action button; keep Coins icon for amount

Backend
- model/topup.go:
  - Add SearchUserTopUps(userId, keyword, pageInfo)
  - Add SearchAllTopUps(keyword, pageInfo)
  - Both support pagination and `trade_no LIKE %keyword%` filtering (ordered by id desc)
- controller/topup.go:
  - GetUserTopUps / GetAllTopUps accept optional `keyword` and route to search functions when present

Routes
- No new endpoints; search is enabled via `keyword` on existing:
  - GET /api/user/topup
  - GET /api/user/topup/self

Affected files
- model/topup.go
- controller/topup.go
- web/src/components/topup/modals/TopupHistoryModal.jsx
2025-10-07 00:55:01 +08:00
Apple\Apple 01d18dcba8 feat(topup): Admin-wide topup listing and route reorganization
Allow administrators to view all platform topup orders and streamline admin-only routes.

Frontend
- TopupHistoryModal: dynamically switch endpoint by role
  - Admin → GET /api/user/topup (all orders)
  - Non-admin → GET /api/user/topup/self (own orders)
- Use shared utils `isAdmin()`; keep logic centralized and DRY
- Minor UI: set admin action button theme to outline for clarity

Backend
- model/topup.go: add GetAllTopUps(pageInfo) with pagination (ordered by id desc)
- controller/topup.go: add GetAllTopUps handler returning PageInfo response
- router/api-router.go:
  - Add admin route GET /api/user/topup (AdminAuth)
  - Move POST /api/user/topup/complete to adminRoute (keeps path stable, consolidates admin endpoints)

Security/Behavior
- Admin-only endpoints now reside under the admin route group with AdminAuth
- No behavior change for regular users; no schema changes

Affected files
- model/topup.go
- controller/topup.go
- router/api-router.go
- web/src/components/topup/modals/TopupHistoryModal.jsx
2025-10-07 00:46:47 +08:00
Apple\Apple dec3a32397 feat: Add topup billing history with admin manual completion
Implement comprehensive topup billing system with user history viewing and admin management capabilities.

## Features Added

### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization

### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling

### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages

## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants

## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
Seefs 5640cfa44e Merge pull request #1974 from RedwindA/fix/Visibility
fix: improve text visibility in warning box for dark mode in SettingsLog
2025-10-06 14:51:07 +08:00
CaIon f56d1ff27e CI 2025-10-06 14:33:48 +08:00
RedwindA 66a956d965 fix: improve text visibility in warning box for dark mode in SettingsLog 2025-10-05 23:39:20 +08:00
CaIon b982bd8bd1 feat: implement user preferences management in SettingsLog component for improved customization 2025-10-05 23:11:45 +08:00
CaIon a058cb1180 feat: enhance SettingsLog component with confirmation modal for log deletion and improve user feedback 2025-10-05 22:51:28 +08:00
CaIon 1ffbf8918c chore: update README files 2025-10-05 19:40:31 +08:00
CaIon 35105b3da5 feat: update Gemini API response handling to include block reason and improve error reporting 2025-10-05 19:33:47 +08:00
CaIon d76ee92f7d feat: set data directory path in main process and update preload.js to use it 2025-10-05 18:38:25 +08:00
CaIon 4675ea5020 fix: update versioning logic in electron-build.yml to correctly handle prerelease formats and modify product name in package.json 2025-10-05 18:20:43 +08:00
CaIon 3c94d1497f chore: update electron-build.yml to add write permissions and enable file overwriting in artifact uploads 2025-10-05 17:50:35 +08:00
CaIon f46d6972b7 feat: enhance versioning logic in electron-build.yml for semver compliance 2025-10-05 17:31:01 +08:00
Calcium-Ion 7f274f69e9 Merge pull request #1964 from bubblepipe/electron
feat: Add Electron wrapper for desktop app
2025-10-05 17:20:23 +08:00
CaIon d85f678982 chore: upgrade action-gh-release to v2 in build workflows 2025-10-05 17:19:52 +08:00
CaIon 65c430f6f0 fix: correct Windows path handling in preload.js and update .gitignore for consistency 2025-10-05 17:15:10 +08:00
CaIon 32f806d562 chore: update build workflows for Electron and Go, including version tagging and dependency management 2025-10-05 17:11:30 +08:00
CaIon 02c0cbcc6a feat: enhance Electron environment detection and improve database warnings 2025-10-05 16:45:29 +08:00
CaIon 9ace186030 feat: 防呆优化 2025-10-05 15:44:00 +08:00
Seefs c217e05f2c Merge pull request #1967 from MomentDerek/main
fix: Gemini missing func name for multi-streaming tool calls (except the first)
2025-10-04 14:53:51 +08:00
Moment b2d3fa5d95 fix: Gemini missing func name for multi-streaming tool calls (except the first). 2025-10-04 13:21:07 +08:00
CaIon 9aaa0f4830 💱 feat: implement currency configuration helper and update currency display logic in RechargeCard and render functions 2025-10-03 21:49:24 +08:00
CaIon 0077957630 chore: remove outdated instructions from pull request template 2025-10-03 21:40:07 +08:00
CaIon 3a7f42e5a1 💱 feat(RechargeCard): enhance currency display logic for top-up amounts based on user settings 2025-10-03 21:39:28 +08:00
Calcium-Ion 2958114205 Merge pull request #1957 from seefs001/pr/custom-currency-1923
💱 feat(settings): introduce site-wide quota display type
2025-10-03 21:17:16 +08:00
CaIon 9942487889 chore: update README files 2025-10-03 20:28:26 +08:00
CaIon d6b127bfcb chore: update README files 2025-10-03 20:27:34 +08:00
CaIon e3b18567ea feat: add Spanish feature request template to GitHub issue tracker for improved feature proposal submissions 2025-10-03 14:45:39 +08:00
bubblepipe42 2c1af1cfaa fix deps 2025-10-03 14:30:48 +08:00
bubblepipe42 7c6e6624aa merge 2025-10-03 14:29:45 +08:00
bubblepipe42 e9aa45a589 fix deps 2025-10-03 14:28:29 +08:00
CaIon b4c70b008e feat: add English feature request template to GitHub issue tracker for enhanced feature proposal submissions 2025-10-03 14:01:16 +08:00
CaIon 6267ed925e feat: add English bug report template to GitHub issue tracker for improved issue reporting #1960 2025-10-03 13:59:27 +08:00
bubblepipe42 07f858adbb action 2025-10-03 13:55:19 +08:00
bubblepipe42 20055ca091 action 2025-10-03 13:23:56 +08:00
Seefs c3e28946a4 Merge pull request #1963 from feitianbubu/pr/refactor-channel-test
refactor: simplify unsupported test channel types
2025-10-03 12:46:38 +08:00
CaIon 7b5d406785 refactor(openai_image): replace json.Marshal with common.Marshal for improved serialization #1961 2025-10-03 12:44:33 +08:00
feitianbubu 0e6ccea09b refactor: simplify unsupported test channel types 2025-10-03 12:41:39 +08:00
Seefs b89f929642 Merge pull request #1959 from RedwindA/feat/silicon-fim
feat: Allow FIM chat requests without messages
2025-10-03 12:37:38 +08:00
CaIon ca3304a841 feat(gemini): add imageConfig field to GeminiChatRequest for flexible image configuration 2025-10-03 12:26:17 +08:00
RedwindA 2c669cce91 feat: Allow FIM chat requests without messages 2025-10-03 02:27:02 +08:00
Seefs b65e271971 Merge branch 'main-upstream' into pr/custom-currency-1923
# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/channels/useChannelsData.jsx
#	web/src/hooks/common/useSidebar.js
#	web/src/i18n/locales/fr.json
#	web/src/pages/Setting/Operation/SettingsGeneral.jsx
2025-10-02 20:30:48 +08:00
CaIon 8b6b5d810d docs: update README files to include Japanese language support 2025-10-02 19:55:37 +08:00
CaIon 48d649056c feat(api): add header override processing with variable support 2025-10-02 19:29:57 +08:00
CaIon a6f08a681f Merge remote-tracking branch 'origin/main' 2025-10-02 19:01:13 +08:00
Calcium-Ion bc65776227 Merge pull request #1890 from QuantumNous/pr/console-footer
 feat(layout): refine footer visibility logic to target CardPro component pages
2025-10-02 19:00:57 +08:00
CaIon 21de4395b5 refactor(footer): update footer links and localization text
- Removed the 'chatnio' link from the footer.
- Added new links for 'CoAI' and 'GPT-Load' in the footer.
- Updated the localization key for '基于New API的项目' to '友情链接' for better clarity.
- Adjusted the design of the footer to improve layout and visibility of the developer credit.
2025-10-02 19:00:07 +08:00
CaIon 670e5f4dbe Merge remote-tracking branch 'origin/pr/console-footer' into pr/console-footer
# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/common/useSidebar.js
2025-10-02 18:46:16 +08:00
t0ng7u 2b1f1fa124 feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-10-02 18:45:37 +08:00
CaIon 995ac44256 Merge remote-tracking branch 'origin/main' 2025-10-02 18:43:32 +08:00
Calcium-Ion a489a8befc Merge pull request #1932 from seefs001/chore/upgrade-deps
chore: go version & sonic dep
2025-10-02 18:37:56 +08:00
Calcium-Ion fa20efa45c Merge pull request #1940 from comeback01/french-translation-final
feat(i18n): add and update French translations
2025-10-02 18:37:34 +08:00
comeback01 f8d35536d4 Merge branch 'main' into french-translation-final 2025-10-02 10:57:34 +02:00
CaIon c244a19acb chore(docker): add comment for compatibility with older Docker versions 2025-10-02 16:20:15 +08:00
CaIon 02eb7714b6 chore(docker): switch from MySQL to PostgreSQL in docker-compose configuration 2025-10-02 16:14:15 +08:00
CaIon 9d418d0df8 refactor(adaptor): extract common header operations into a separate function 2025-10-02 15:28:09 +08:00
Calcium-Ion 6fd746a339 Merge pull request #1956 from QuantumNous/alpha
alpha -> main
2025-10-02 15:26:59 +08:00
bubblepipe42 f50ca4fd21 windows tray icon 2025-10-02 14:55:06 +08:00
Seefs 76ce614f35 Merge pull request #1779 from feitianbubu/pr/fix-video-get-task
fix: get video task err when Content-Type=json
2025-10-02 14:43:18 +08:00
Seefs f8c9763a64 Merge branch 'alpha' into pr/fix-video-get-task 2025-10-02 14:43:11 +08:00
Seefs 621ee41f12 Merge pull request #1951 from feitianbubu/pr/add-doubao-video
增加豆包视频渠道
2025-10-02 14:41:41 +08:00
Seefs d6350bc5ac Merge pull request #1955 from seefs001/fix/megge-conflict
fix: merge conflict
2025-10-02 14:30:25 +08:00
Seefs e7b1ff2f2a fix: merge conflict 2025-10-02 14:28:58 +08:00
Seefs 6303f99b62 Merge pull request #1844 from JoeyLearnsToCode/feat-channel-block-edit
feat: Add navigation buttons for channel edit form sections
2025-10-02 14:16:11 +08:00
Seefs 82361eb853 Merge branch 'alpha' into feat-channel-block-edit 2025-10-02 14:16:01 +08:00
Seefs 4599941e78 Merge pull request #1947 from HynoR/feat/newedit
feat: Add visual editing mode for chat configurations
2025-10-02 14:11:49 +08:00
Seefs 7f6c814ac4 Merge pull request #1948 from RedwindA/feat/gotify
feat: Add Gotify Notification Channel for Quota Alerts
2025-10-02 14:11:20 +08:00
Seefs c2579f1666 Merge pull request #1950 from seefs001/fix/missing-field
fix: missing field & field control
2025-10-02 14:10:11 +08:00
Seefs 799de43cff fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields 2025-10-02 13:57:49 +08:00
Seefs 711cbeb1c5 Merge pull request #1954 from QuantumNous/main
main -> alpha
2025-10-02 13:50:18 +08:00
feitianbubu 1263c95548 feat: add doubao video add log detail 2025-10-02 04:00:50 +08:00
feitianbubu 3c796a1166 feat: add doubao video use quota by total token 2025-10-02 04:00:46 +08:00
feitianbubu c25ca9a791 feat: add doubao video generate 2025-10-02 04:00:43 +08:00
Seefs d6bb7a4cbf Merge pull request #1949 from RedwindA/fix/oai-responses-webSearch-panic
fix(openai): add nil checks for web_search streaming to prevent panic
2025-10-02 00:36:25 +08:00
Seefs d075fbee23 fix: missing field & field control 2025-10-02 00:14:35 +08:00
RedwindA 78f34bd095 fix(openai): add nil checks for web_search streaming to prevent panic 2025-10-01 22:19:22 +08:00
RedwindA 00cf826c4e feat: 添加 Bark 和 Gotify 通知的国际化支持 2025-10-01 19:36:19 +08:00
RedwindA 98050ee6e9 feat: add Gotify notification option for quota alerts 2025-10-01 19:15:00 +08:00
HynoR 019704f09b feat: Enhance SettingsChats edit interface 2025-10-01 18:40:02 +08:00
HynoR 60e14b2207 feat: add visual editing mode for chat configurations 2025-10-01 18:22:32 +08:00
Seefs 7b96f7c8e7 Merge pull request #1945 from QuantumNous/gemini-robotics-er
feat: 支持 gemini-robotics-er-1.5-preview
2025-10-01 17:41:41 +08:00
creamlike1024 eeef719f3a feat: 支持 gemini-robotics-er-1.5-preview 2025-10-01 17:33:54 +08:00
Seefs f3fe6b9519 Merge pull request #1943 from RedwindA/fix/jina-embedding
fix(jina): remove encoding_format for jina embedding
2025-10-01 16:58:56 +08:00
RedwindA fb428ee9c0 fix(jina): remove encoding_format for jina embedding 2025-10-01 02:06:30 +08:00
comeback01 075c134abc feat(i18n): add and update French translations 2025-09-30 17:07:45 +02:00
CaIon 121e01c764 fix(adaptor): update relay mode handling to support relay format 2025-09-30 16:53:57 +08:00
CaIon 63bbd9e3c3 feat: add endpoint type selection to channel testing functionality 2025-09-30 16:52:14 +08:00
bubblepipe e66eff66a7 fix wn 2025-09-30 16:41:41 +08:00
Calcium-Ion f92d783496 Merge pull request #1936 from seefs001/fix/passkey
fix: passkey 文案
2025-09-30 16:19:01 +08:00
Seefs ce1f62b27c fix: passkey 文案 2025-09-30 16:15:33 +08:00
bubblepipe 97a8f25d88 fix build on windows 2025-09-30 15:55:31 +08:00
Calcium-Ion d826be82af Merge pull request #1934 from seefs001/fix/passkey
fix: passkey rpid detect
2025-09-30 15:54:49 +08:00
Seefs 75ee632182 fix: passkey rpid detect 2025-09-30 15:53:19 +08:00
Seefs 3b22408b61 chore: go version & sonic dep 2025-09-30 14:38:20 +08:00
Seefs 92f81c63e3 Merge pull request #1931 from seefs001/feature/passkey
fix: passkey security
2025-09-30 13:28:18 +08:00
Seefs 112780eb96 fix: passkey security 2025-09-30 13:18:18 +08:00
Seefs fcbc84d14e Merge pull request #1930 from seefs001/feature/passkey
fix: passkey model type
2025-09-30 12:52:32 +08:00
Seefs f3477cb267 fix: blob type 2025-09-30 12:40:05 +08:00
Seefs 3c02c0f943 Merge pull request #1912 from seefs001/feature/passkey
feat: passkey
2025-09-30 12:28:09 +08:00
Seefs 9e55266383 fix: personal setting 2025-09-30 12:26:24 +08:00
Seefs e26c794742 Merge branch 'main-upstream' into feature/passkey
# Conflicts:
#	web/src/components/settings/PersonalSetting.jsx
#	web/src/i18n/locales/en.json
#	web/src/i18n/locales/zh.json
2025-09-30 12:15:20 +08:00
Seefs 8eace6bb62 feat: 通用二步验证 2025-09-30 12:12:50 +08:00
Seefs 08b424a35e Merge pull request #1817 from wzxjohn/hotfix/relay_vertex_claude
fix(relay): wrong URL for claude model in GCP Vertex AI
2025-09-30 11:27:15 +08:00
CaIon b211f593c3 fix(main): conditionally log missing .env file message based on debug mode 2025-09-30 11:22:00 +08:00
Calcium-Ion b6a3d80979 Merge pull request #1920 from QuantumNous/alpha
Alpha -> main
2025-09-30 11:16:45 +08:00
Seefs 853814045a Merge pull request #1925 from seefs001/feature/claude-context-editing
feat: claude context editing
2025-09-30 09:48:32 +08:00
Seefs bc1971843d Merge pull request #1926 from seefs001/fix/claude-beta
fix: claude beta=true
2025-09-30 09:48:18 +08:00
Seefs 946da49721 fix: claude beta=true 2025-09-30 09:46:46 +08:00
Seefs 658561645d Merge pull request #1921 from RedwindA/refactor/improve-sidebar-perf
fix: Optimize sidebar refresh to avoid redundant loading states
2025-09-30 09:38:45 +08:00
Seefs 740c46102b Merge pull request #1924 from prnake/claude-4-5
feat: support claude-sonnet-4-5-20250929
2025-09-30 09:26:34 +08:00
Seefs a7f6065f42 feat: claude context editing 2025-09-30 09:22:40 +08:00
papersnake 112ab0b17c feat: support claude-sonnet-4-5-20250929 2025-09-30 09:14:12 +08:00
t0ng7u 8294a76bc2 💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)
Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.

Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
  registration via `config.GlobalConfig.Register("general_setting", ...)`.
  Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
  `display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
  CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
  - `billing`: compute subscription/usage amounts based on the selected type
    (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
  - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
    token-count for TOKENS; adjust min topup and pay money accordingly.
  - `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
  `DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
  (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.

Frontend
- Settings: replace the “display in currency” switch with a Select
  (`general_setting.quota_display_type`) offering USD / CNY / Tokens.
  Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
  for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
  use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
  while TOKENS mode still allows per-view currency toggling when needed.

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
2025-09-29 23:23:31 +08:00
RedwindA c00e5ca201 fix: Optimize sidebar refresh to avoid redundant loading states 2025-09-29 22:16:25 +08:00
Seefs 4daa298770 Merge pull request #1919 from seefs001/fix/submodel
fix : fix submodel adapter
2025-09-29 21:56:53 +08:00
Seefs 60045cdceb fix: submodel adapter 2025-09-29 21:55:34 +08:00
Seefs 722e2c2afd feat: passkey wip 2025-09-29 21:54:16 +08:00
Seefs eeb6b6c7ed Merge pull request #1915 from danding5/main
add submodel.ai
2025-09-29 21:54:00 +08:00
Seefs 59e2c884bb Merge pull request #1916 from RedwindA/fix/3rd-binding-state
fix: sync third-party binding state in personal settings
2025-09-29 20:24:58 +08:00
Seefs fa73e2742f Merge pull request #1918 from tbphp/fix-tg-302
fix: Redirect address after successful tg binding
2025-09-29 20:24:28 +08:00
tbphp 3b6160ecb4 fix: Redirect address after successful tg binding 2025-09-29 20:19:34 +08:00
dd 509b23982d Update constant/api_type.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-29 19:37:04 +08:00
RedwindA 9e271b0680 fix: sync third-party binding state in personal settings 2025-09-29 19:23:42 +08:00
dd f9399e279c Merge branch 'QuantumNous:main' into main 2025-09-29 19:15:43 +08:00
Seefs 41ea93883b Merge pull request #1913 from RedwindA/fix/deepseek-ratio
解锁deepseek补全倍率;允许deepseek渠道获取模型
2025-09-29 18:41:33 +08:00
RedwindA 71b3c90bc9 解锁deepseek补全倍率;允许deepseek渠道获取模型 2025-09-29 18:32:44 +08:00
Seefs 1599a8403f feat: passkey 2025-09-29 17:45:09 +08:00
CaIon 6ca88d1699 fix(models): increase varchar length for TaskID and Username fields #1905 2025-09-29 16:45:46 +08:00
CaIon d96f1b2ac2 Merge remote-tracking branch 'origin/main' 2025-09-29 16:20:59 +08:00
Calcium-Ion a46b3527b4 Merge pull request #1889 from comeback01/traduction
feat(i18n): Add French language support.
2025-09-29 16:20:49 +08:00
CaIon e340061aed chore: remove bun_output.log and package-lock.json files 2025-09-29 16:19:54 +08:00
dd d46e6d42ef Update render.jsx 2025-09-29 15:14:02 +08:00
comeback01 0b03de383e feat(i18n): add French translation for stripe promo codes 2025-09-29 09:12:37 +02:00
dd 64e2ae56a2 Update relay_adaptor.go 2025-09-29 15:11:17 +08:00
comeback01 39a6995eb1 Chore: remove temporary verification script 2025-09-29 09:03:54 +02:00
comeback01 51e02785e6 Merge branch 'main' into traduction
# Conflicts:
#	web/src/i18n/locales/zh.json
2025-09-29 09:03:32 +02:00
dd 40a6d957b5 Merge branch 'QuantumNous:main' into main 2025-09-29 14:13:50 +08:00
CaIon f03f4187e0 fix(http_client): improve error message for unsupported proxy schemes 2025-09-29 14:02:15 +08:00
bubblepipe42 fe6dbbcffe icon 2025-09-29 13:41:17 +08:00
bubblepipe42 2af0bf9d91 README 2025-09-29 13:33:41 +08:00
bubblepipe42 11f50478a7 tray 2025-09-29 13:25:22 +08:00
bubblepipe42 834ab29a46 stash 2025-09-29 13:01:07 +08:00
Seefs 78b9b21a05 Merge pull request #1910 from seefs001/fix/volcengine_default_baseurl
alpha -> main
2025-09-29 12:30:24 +08:00
Seefs 6eabe26ef7 Merge branch 'main-upstream' into fix/volcengine_default_baseurl
# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
2025-09-29 12:17:44 +08:00
Seefs e271e09945 fix: set volcengine default url 2025-09-29 12:15:38 +08:00
Seefs a48f6a7d4a Merge pull request #1904 from RedwindA/fix/wechat-display
Fix third-party binding states and unify Telegram button styling in Account Management
2025-09-29 12:14:23 +08:00
Seefs e28e127a14 Merge pull request #1894 from RedwindA/refactor/enhance-channel-proxy
Refactor: Cache Proxy HTTP Clients with Reset on Channel Updates
2025-09-29 12:12:17 +08:00
Seefs 343d8091fc Merge branch 'main-upstream' into fix/volcengine_default_baseurl
# Conflicts:
#	main.go
2025-09-29 12:08:52 +08:00
bubblepipe42 be02a73df2 electron 2025-09-29 11:08:52 +08:00
comeback01 cbbe4cb4e7 Merge remote-tracking branch 'upstream/main' into traduction 2025-09-28 12:23:17 +02:00
RedwindA 1b51a7e311 fix(settings): ensure turnstile settings are reset when disabled 2025-09-28 17:38:56 +08:00
RedwindA 3bbba55343 fix(settings): correct third-party binding states and unify Telegram
button styling

  - Show “Not enabled” for WeChat when status.wechat_login is false.
  - Refresh /api/status on PersonalSetting mount and persist via
  setStatusData to avoid stale flags, enabling binding after admin turns
  on OAuth.
  - Unify Telegram button styling with other providers; open a modal to
  render TelegramLoginButton for binding; align disabled “Not enabled” and
  “Bound” states.
  - Introduce isBound helper and reuse across providers (email/GitHub/OIDC/
  LinuxDO/WeChat) to simplify checks and prevent falsy-ID issues.
2025-09-28 17:31:38 +08:00
Calcium-Ion 1ba2cb0b92 Merge pull request #1897 from QuantumNous/openrouter-enterprise
feat: 添加 openrouter-enterprise 支持
2025-09-28 15:31:01 +08:00
CaIon 6f8edd49c4 fix: streamline error handling in OpenRouter response processing 2025-09-28 15:29:01 +08:00
CaIon 49d58fbc71 Merge remote-tracking branch 'origin/openrouter-enterprise' into openrouter-enterprise
# Conflicts:
#	relay/channel/openai/relay-openai.go
2025-09-28 15:23:40 +08:00
CaIon 6682842cbb feat: enhance OpenRouter enterprise support with new settings and response handling 2025-09-28 15:23:27 +08:00
Calcium-Ion 11ce2a6de4 Merge pull request #1819 from RixAPI/main
优化渠道测试
2025-09-28 14:31:29 +08:00
Calcium-Ion 42a29e485e Merge pull request #1811 from somnifex/main
refactor: 重构ollama渠道
2025-09-28 14:30:45 +08:00
JoeyLearnsToCode 8ed3e4f034 Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit 2025-09-28 09:56:35 +08:00
creamlike1024 9fe5f9dd7a feat: 添加 openrouter-enterprise 支持 2025-09-28 09:35:07 +08:00
Calcium-Ion 4bbd59c740 Merge pull request #1836 from joesonshaw/main
fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
2025-09-28 01:17:22 +08:00
Little Write 6a2071cb38 Merge branch 'main' into feat_subscribe_sp1 2025-09-27 22:54:52 +08:00
RedwindA 4f3ec81b3f fix: add missing timeout 2025-09-27 22:29:27 +08:00
RedwindA 2437c90d68 feat: 在添加和更新渠道时重置代理客户端缓存 2025-09-27 22:18:46 +08:00
RedwindA fcc5c73bc0 feat: 添加代理客户端缓存和重置功能,优化 HTTP 客户端管理 2025-09-27 22:18:39 +08:00
Little Write 764aaef8d9 完善了 翻译 2025-09-27 21:22:09 +08:00
Seefs 1d4d243f66 Merge pull request #1886 from ShibaInu64/feature/volcengine-base-url
feat: volcengine支持自定义域名
2025-09-27 20:06:04 +08:00
huanghejian 95cbab6b19 Merge remote-tracking branch 'origin/main' into feature/volcengine-base-url 2025-09-27 19:53:42 +08:00
t0ng7u 458fd242c6 feat(layout): refine footer visibility logic to target CardPro component pages
- Replace blanket console route footer hiding with specific page targeting
- Only hide footer on pages that use CardPro component:
  * /console/channel (channels management)
  * /console/log (usage logs)
  * /console/redemption (redemption codes)
  * /console/user (user management)
  * /console/token (token management)
  * /console/midjourney (midjourney logs)
  * /console/task (task logs)
  * /console/models (model management)
  * /pricing (pricing page)
- Footer now displays on other console pages (dashboard, settings, topup, etc.)
- Improves UI consistency by showing footer where CardPro's internal pagination isn't used

This change ensures footer is only hidden when CardPro component provides its own
pagination/footer functionality, while preserving footer visibility on other pages
that benefit from the global footer navigation.
2025-09-27 18:47:53 +08:00
Little Write 3251dc0bc8 调整 优化代码细节 2025-09-27 18:04:48 +08:00
comeback01 05d3ddb397 fix(test): Make language verification script more robust
This addresses feedback from CodeRabbitAI by using a regular expression for the language button's aria-label. This ensures the test can run regardless of the browser's default language.
2025-09-27 11:35:03 +02:00
google-labs-jules[bot] 91ea27541c fix(i18n): nest common.changeLanguage under common object
- Restructured the `common.changeLanguage` key to be nested under a `common` object in `en.json`, `fr.json`, and `zh.json`.
- This change improves the organization of the translation files and aligns with best practices for i18next.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot] 9f0829adb9 feat(i18n): complete French locale and add common.changeLanguage
- Added `common.changeLanguage` key to `en.json`, `fr.json`, and `zh.json`.
- Updated `LanguageSelector.jsx` to use the new shared key.
- Completed `fr.json` with all keys from `en.json` and `zh.json`.
- Added translations for `closeSidebar`, `pricing`, and `language`.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot] bf6a4f3b85 Ajout du lien vers la traduction française dans le fichier README.en.md. 2025-09-27 11:18:54 +02:00
google-labs-jules[bot] ee6990b017 Ajout du lien vers la traduction française dans le fichier README.md principal. 2025-09-27 11:18:54 +02:00
google-labs-jules[bot] b2ee8a236d Ajout de la traduction française du fichier README.
- Création du fichier `README.fr.md` en se basant sur `README.en.md`.
2025-09-27 11:18:54 +02:00
google-labs-jules[bot] bea5dcd506 Ajout de la traduction française à l'interface utilisateur.
- Création du fichier de traduction `fr.json` en se basant sur `en.json`.
- Mise à jour de la configuration i18n pour inclure la langue française.
- Modification du sélecteur de langue pour afficher l'option "Français" avec le drapeau correspondant.
2025-09-27 11:18:54 +02:00
huanghejian 183ae15a57 fix: 默认选择国内地址,不设置base url弹窗提示 2025-09-27 17:03:34 +08:00
huanghejian 7dbdd91b30 pref: 防呆设计需要,不允许自定义,API地址优化为下拉选择 2025-09-27 16:40:18 +08:00
CaIon da88e746ef feat: add startup logging with network IPs and container detection 2025-09-27 16:30:24 +08:00
Seefs db3dd8e386 Merge pull request #1862 from HynoR/fix/dup3
feat: add duplicate key removal function when edit or add new channel
2025-09-27 16:21:14 +08:00
CaIon b836bce81c feat: add startup logging with network IPs and container detection 2025-09-27 16:19:58 +08:00
Seefs acd5a47306 Merge pull request #1554 from feitianbubu/pr/fix-video-preview
feat: if video cannot play open in a new tab
2025-09-27 16:19:25 +08:00
Seefs 2ba55243f8 Merge pull request #1888 from seefs001/feature/gemini-urlcontext
feat: gemini urlContext
2025-09-27 16:18:14 +08:00
Seefs 23c1bd472e feat: gemini urlContext 2025-09-27 16:16:34 +08:00
Seefs 8482b794ed Merge pull request #1887 from seefs001/fix/stripe
feat: allow stripe promotion code
2025-09-27 15:46:35 +08:00
Seefs bd6f4dee73 feat: allow stripe promotion code 2025-09-27 15:43:12 +08:00
Seefs e81005d104 Merge pull request #1070 from wzxjohn/feature/umami
feat: support UMAMI analytics
2025-09-27 15:30:54 +08:00
Seefs e6525eea7f Merge pull request #1853 from ZKunZhang/fix/playground-copy-newlines
fix: 修复多行代码复制换行丢失问题 & 优化API参数处理#1828
2025-09-27 15:25:44 +08:00
CaIon 5f34c4a97d feat: update release configuration to use new-api binaries for consistency 2025-09-27 15:24:40 +08:00
huanghejian d1f590aa7b pref: 优化代码 2025-09-27 15:19:54 +08:00
Calcium-Ion 5b26120bb8 Merge pull request #1885 from RedwindA/fix/hide-unavailable-fetch-model-button
feat: `获取模型列表`按钮白名单
2025-09-27 15:12:01 +08:00
Calcium-Ion 21f48f2c89 Merge pull request #1884 from seefs001/fix/gemini
fix: add missing fields to Gemini request
2025-09-27 15:10:49 +08:00
CaIon f50eb9dde7 feat: rename output binaries to new-api for consistency across platforms 2025-09-27 15:04:06 +08:00
CaIon e94d6f1581 Merge remote-tracking branch 'origin/main' 2025-09-27 13:56:44 +08:00
CaIon 1235216c05 feat: 多密钥管理新增针对单个密钥的删除操作 2025-09-27 13:56:07 +08:00
Little Write 447166acb9 Update model/topup.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-27 11:42:18 +08:00
Little Write 90e66fe774 Update controller/topup_creem.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-27 11:41:34 +08:00
Little Write 2a76fab170 Update controller/topup_creem.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-27 11:41:03 +08:00
Little Write cfc96c9c93 Update controller/topup_creem.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-27 11:40:19 +08:00
huanghejian 690d103021 feat: volcengine支持自定义域名 2025-09-27 09:35:03 +08:00
RedwindA 395f06f488 feat: 添加对 Zhipu v4 渠道获取模型列表的支持 2025-09-27 01:19:43 +08:00
RedwindA ee6e5ff882 feat: 仅为适当的渠道渲染获取模型列表按钮 2025-09-27 01:19:09 +08:00
Seefs 53513cbe1d fix: jsonRaw 2025-09-27 00:33:05 +08:00
Seefs b66316fe6f fix: jsonRaw 2025-09-27 00:24:29 +08:00
Seefs b3b6ec0375 fix: add missing fields to Gemini request 2025-09-27 00:15:28 +08:00
Seefs 84d00fb75b Merge pull request #1882 from ShibaInu64/feature/nova-model
feat: 新增支持目前已发布的amazon nova model
2025-09-26 17:25:40 +08:00
huanghejian a069d03ef7 feat: amazon nova model 2025-09-26 15:55:00 +08:00
huanghejian 5545e70a42 feat: amazon nova model 2025-09-26 15:32:59 +08:00
IcedTangerine 0637aee102 Merge pull request #1809 from QuentinHsu/feature/date-shortcut
feat: add date range preset constants and use them in the log filter
2025-09-23 22:24:05 +08:00
IcedTangerine b118b331f1 Merge branch 'main' into feature/date-shortcut 2025-09-23 22:21:25 +08:00
IcedTangerine b22a3f5048 Merge pull request #1810 from QuentinHsu/feature/alias-path
feat: add jsconfig.json and configure path aliases
2025-09-23 22:13:20 +08:00
Seefs 06280a6ed3 Merge pull request #1830 from MyPrototypeWhat/fix/UserArea-Dropdown
fix(UserArea): Enhance UserArea dropdown positioning with useRef
2025-09-22 12:59:54 +08:00
Seefs a3f7414260 Merge pull request #1777 from heimoshuiyu/feat/token-display-thoundsand-seperator
feat: add thousand separators to token display in dashboard
2025-09-22 12:56:49 +08:00
Seefs 83fc8cf8ef Merge pull request #1769 from QAbot-zh/fix/account-status
fix:Account Management Status
2025-09-22 12:48:51 +08:00
HynoR afecbb07c4 feat: add duplicate key removal function when edit or add new channel 2025-09-21 17:26:56 +09:00
IcedTangerine 0db8892f1d Merge pull request #1841 from x-Ai/fix/sidebar-permissions
fix: 个人设置中修改边栏设置不立即生效
2025-09-21 15:08:18 +08:00
Seefs dc24117509 Merge pull request #1850 from seefs001/fix/claude-system-prompt-overwrite
fix: claude & gemini endpoint system prompt overwrite
2025-09-20 14:00:38 +08:00
Seefs 3638bf149c fix: gemini system prompt overwrite 2025-09-20 13:38:44 +08:00
CaIon eeac99731b feat: change ParallelToolCalls and Store fields to json.RawMessage type 2025-09-20 13:28:33 +08:00
CaIon c0574a0e53 feat: add PromptCacheKey field to openai_request struct 2025-09-20 13:27:32 +08:00
Zhaokun Zhang 6f5dd3487c fix: address copy functionality and code logic issues for #1828
- utils.jsx: Replace input with textarea in copy function to preserve line breaks in multi-line content, preventing formatting loss mentioned in #1828
- api.js: Fix duplicate 'group' property in buildApiPayload to resolve syntax issues
- MarkdownRenderer.jsx: Refactor code text extraction using textContent for accurate copying

Closes #1828

Signed-off-by: Zhaokun Zhang <zhaokunzhang@Zhaokuns-Air.lan>
2025-09-20 11:09:28 +08:00
Seefs 7384b0925e fix: claude system prompt overwrite 2025-09-20 00:22:54 +08:00
joesonshaw 04848bd28b Merge branch 'QuantumNous:main' into main 2025-09-19 23:31:43 +08:00
creamlike1024 d6f1b8621a Merge branch 'feitianbubu-pr/vidu-add-first-end-reference-video' 2025-09-19 22:47:29 +08:00
feitianbubu d29fbd378d feat: vidu video add starEnd and reference gen video show type 2025-09-19 18:54:48 +08:00
feitianbubu b183f2f663 feat: vidu video add starEnd and reference gen video 2025-09-19 18:54:45 +08:00
feitianbubu 22db389fac feat: vidu video support multi images 2025-09-19 18:54:40 +08:00
JoeyLearnsToCode a23955a025 feat: jump between section on channel edit page 2025-09-19 18:11:47 +08:00
F。 09374778bd 改进"侧边栏"权限控制-1
This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0.
2025-09-19 16:24:37 +08:00
F。 647ed1be83 改进"侧边栏"权限控制 2025-09-19 16:24:31 +08:00
F。 b79fe6cff0 修复"边栏"权限控制问题 2025-09-19 16:24:27 +08:00
F。 354e866a5b 修复"边栏"隐藏后无法即时生效问题 2025-09-19 16:24:23 +08:00
CaIon d77aa81f4f feat: update labels for ratio settings to clarify model support 2025-09-19 14:23:08 +08:00
CaIon abfb35b53b fix: cast option.Value to string for ratio updates 2025-09-19 14:21:32 +08:00
CaIon fdc3030e47 CI 2025-09-19 14:20:35 +08:00
CaIon 552d795742 Merge branch 'alpha' 2025-09-19 14:20:15 +08:00
wzxjohn 237f599422 fix(relay): wrong key param while enable sse 2025-09-19 11:22:03 +08:00
joesonshaw 241b92b28e fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
将连接延迟关闭逻辑调整到协程中执行,防止在完全接收到所有数据前提前关闭
2025-09-19 10:49:47 +08:00
Calcium-Ion 703d9265bb Merge pull request #1834 from QuantumNous/gemini-embedding-001
feat: 支持 gemini-embedding-001
2025-09-19 00:40:40 +08:00
creamlike1024 5cae6be108 feat: 支持 gemini-embedding-001 2025-09-19 00:24:01 +08:00
Seefs e81e2ee3f2 Merge pull request #1833 from seefs001/feature/deepseek-claude-code
fix: deepseek claude response
2025-09-18 16:37:51 +08:00
Seefs d4fc4f280e feat: deepseek claude endpoint 2025-09-18 16:32:29 +08:00
Calcium-Ion ec4d2de47e Merge pull request #1832 from seefs001/feature/deepseek-claude-code
feat: deepseek claude endpoint
2025-09-18 16:30:01 +08:00
Seefs 06ccdcbaa4 feat: deepseek claude endpoint 2025-09-18 16:19:44 +08:00
Calcium-Ion a1b5ae8b0a Merge pull request #1831 from seefs001/fix/kimi-claude-code
fix: kimi claude code
2025-09-18 16:15:41 +08:00
Seefs 7077756241 fix: kimi claude code 2025-09-18 16:14:25 +08:00
CaIon c702abfd7a CI 2025-09-18 13:53:58 +08:00
Calcium-Ion 1f8ae6e65a Merge commit from fork
feat: implement SSRF protection settings and update related references
2025-09-18 13:41:44 +08:00
CaIon 0008d2e3a0 feat: add experimental IP filtering for domains and update related settings 2025-09-18 13:40:52 +08:00
CaIon 23e5132e21 Merge branch 'main' into ssrf
# Conflicts:
#	service/cf_worker.go
2025-09-18 13:29:11 +08:00
MyPrototypeWhat 1c13fc0e04 refactor: Enhance UserArea dropdown positioning with useRef
- Added useRef to manage dropdown positioning in UserArea component.
- Wrapped Dropdown in a div with a ref to ensure correct popup container.
- Minor adjustments to maintain existing functionality and styling.
2025-09-18 12:01:35 +08:00
creamlike1024 98d5b3dbcb feat: 移除多余的说明文本 2025-09-17 23:54:34 +08:00
creamlike1024 7af1dc42d4 fix: use u.Hostname() instead of u.Host to avoid ipv6 host parse failed 2025-09-17 23:47:59 +08:00
creamlike1024 82163b4be7 feat: 添加域名启用ip过滤开关 2025-09-17 23:46:04 +08:00
creamlike1024 f9a6e7f04f feat: remove ValidateURLWithDefaults 2025-09-17 23:29:18 +08:00
creamlike1024 b46bffde03 feat: ssrf支持域名和ip黑白名单过滤模式 2025-09-17 15:41:21 +08:00
creamlike1024 cc9a559b75 feat: 添加域名和ip过滤模式设置 2025-09-16 22:40:40 +08:00
Little Write 12ea0c19e6 完善 订单处理,以及 优化 ui 2025-09-16 22:35:46 +08:00
RixAPI fd8271b94a 优化渠道测试
增加并发支持
2025-09-16 20:03:10 +08:00
Calcium-Ion 3169065b81 Merge pull request #1816 from QuantumNous/fix/setup-err-display
🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
2025-09-16 17:43:57 +08:00
t0ng7u 6c7d28af0b 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 17:21:22 +08:00
wzxjohn da54561b36 fix(relay): wrong URL for claude model in GCP Vertex AI 2025-09-16 17:18:32 +08:00
t0ng7u 9ba37555fd Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-16 16:56:26 +08:00
t0ng7u a2b2f1ce27 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 16:55:35 +08:00
Xyfacai 9fa9cf38c2 feat: jimeng kling 支持new api 嵌套中转 2025-09-16 16:28:27 +08:00
Calcium-Ion cd8f64d83b Merge pull request #1814 from ShibaInu64/fix/volcengine-image-model
fix: VolcEngine渠道-图片生成 API-渠道测试报错
2025-09-16 15:36:56 +08:00
huanghejian f56783c4eb Merge branch 'main' into fix/volcengine-image-model 2025-09-16 14:58:59 +08:00
huanghejian 97755c2e94 fix: VolcEngine doubao-seedream-4-0-250828 2025-09-16 14:30:12 +08:00
Calcium-Ion d91041fa82 Merge pull request #1813 from QuantumNous/fix1797
fix: openai responses api 未统计图像生成调用计费
2025-09-16 14:07:14 +08:00
creamlike1024 b9befccd9e fix: imageGenerationCall involves billing 2025-09-16 13:02:15 +08:00
creamlike1024 f6984272bf fix: openai responses api 未统计图像生成调用计费 2025-09-16 12:47:59 +08:00
somnifex 8c6789656f chore: 删除历史文件 2025-09-16 08:58:19 +08:00
somnifex b48c292cc3 feat: 添加ollama聊天流处理和非流处理功能 2025-09-16 08:58:06 +08:00
somnifex 05241aea92 fix: 修复ollamaChatHandler中ReasoningContent字段的赋值逻辑 2025-09-16 08:54:34 +08:00
somnifex 414b959e65 fix: 添加对Thinking字段的处理逻辑,确保推理内容正确传递 2025-09-16 08:51:29 +08:00
somnifex b80811a412 fix: 修复图像URL处理逻辑,确保正确生成base64数据 2025-09-16 08:13:28 +08:00
somnifex f57c539a1f fix: 修复ollamaChatHandler中的FinishReason字段赋值逻辑 2025-09-15 23:43:39 +08:00
somnifex a1f79761cf refactor: 简化请求转换函数和流处理逻辑 2025-09-15 23:41:09 +08:00
somnifex a9354df549 fix: 优化ollamaStreamHandler中的停止和最终使用响应逻辑 2025-09-15 23:23:53 +08:00
somnifex 503a2669d5 refactor: 更新请求转换逻辑,优化工具调用解析 2025-09-15 23:15:46 +08:00
somnifex cdf0c9dae4 fix: 优化ollamaChatStreamChunk结构体字段格式 2025-09-15 23:09:10 +08:00
somnifex 767d46e829 feat: 重构ollama渠道请求 2025-09-15 23:01:14 +08:00
QuentinHsu fc4660f403 feat: add jsconfig.json and configure path aliases 2025-09-15 22:30:41 +08:00
QuentinHsu 2836ec2eb3 feat: add date range preset constants and use them in the log filter 2025-09-15 21:59:25 +08:00
Xyfacai c176e713f7 fix: 非openai 渠道使用 SystemPrompt 设置会panic 2025-09-15 19:38:31 +08:00
IcedTangerine 46c561eef2 Merge pull request #1805 from feitianbubu/pr/jimeng-video-3.0
feat: 支持即梦视频3.0,支持文生图, 图生图, 首尾帧生图
2025-09-15 17:12:28 +08:00
Calcium-Ion 9af65142d7 Merge pull request #1807 from QuantumNous/fix-stripe-successurl
fix: stripe支付成功未正确跳转
2025-09-15 16:24:20 +08:00
creamlike1024 8f5c29342d fix: stripe支付成功未正确跳转 2025-09-15 16:22:37 +08:00
feitianbubu cf446438ed feat: jimeng video 3.0 req_key convert 2025-09-15 15:54:13 +08:00
feitianbubu 6b028bfedb feat: 支持即梦视频3.0,新增10s(frames=241)支持 2025-09-15 15:10:39 +08:00
DD 1c48391cdb add submodel icon 2025-09-15 14:31:31 +08:00
DD 71d41d6eaa Merge branch 'main' of github.com:danding5/new-api
# Conflicts:
#	relay/relay_adaptor.go
2025-09-15 14:31:06 +08:00
IcedTangerine 6b9c1c3ae8 Merge pull request #1698 from QuantumNous/imageratio-and-audioratio-edit
feat: 图像倍率,音频倍率和音频补全倍率配置
2025-09-15 14:20:05 +08:00
creamlike1024 d21886b9fb Merge branch 'alpha' into imageratio-and-audioratio-edit 2025-09-15 14:12:24 +08:00
Xyfacai b0bb2fe5a9 fix: gemini support webp file 2025-09-15 01:01:48 +08:00
Calcium-Ion 5fd74c3428 Merge pull request #1799 from seefs001/fix/setting
fix: settings
2025-09-14 13:01:09 +08:00
Seefs cb0d3d60d3 fix: settings 2025-09-14 12:59:44 +08:00
CaIon c3f5478593 feat: implement SSRF protection settings and update related references 2025-09-13 18:15:03 +08:00
Calcium-Ion 523ee96bff Merge pull request #1794 from seefs001/fix/veo3
fix veo3 adapter
2025-09-13 17:33:18 +08:00
Seefs a9d3547633 Merge pull request #1795 from seefs001/fix/veo3
Fix/veo3
2025-09-13 16:31:55 +08:00
Seefs 30763deb96 fix veo3 adapter 2025-09-13 16:26:14 +08:00
CaIon 9e17df3f74 fix: update references from setting to system_setting for ServerAddress 2025-09-13 15:27:41 +08:00
Seefs ad0928388a Merge pull request #1792 from QuantumNous/alpha
veo
2025-09-13 13:16:33 +08:00
Seefs 82f0860a96 Merge branch 'main' into alpha 2025-09-13 13:14:34 +08:00
Seefs 2a9e8f6cdc Merge pull request #1659 from Sh1n3zZ/feat-vertex-veo
feat: vertex veo (#1450)
2025-09-13 13:11:02 +08:00
Seefs 8d32b08d44 Merge branch 'alpha' into feat-vertex-veo 2025-09-13 13:10:39 +08:00
CaIon 287b3ab2c9 Merge remote-tracking branch 'origin/main' 2025-09-13 12:54:49 +08:00
CaIon 15d845cd01 fix(adaptor): correct VertexKeyType condition in SetupRequestHeader 2025-09-13 12:53:41 +08:00
CaIon 1a8d781721 Revert "feat: gemini-2.5-flash-image-preview 文本和图片输出计费"
This reverts commit a45513a7a6.
2025-09-13 12:53:28 +08:00
creamlike1024 4b99a996e9 Merge branch 'feitianbubu-pr/add-jimeng-video-images' 2025-09-13 09:57:01 +08:00
feitianbubu 4db3aebb64 feat: jimeng video add images 2025-09-12 22:43:08 +08:00
feitianbubu e55c84f599 refactor: use common taskSubmitReq 2025-09-12 22:43:03 +08:00
CaIon 59d6ff0caf feat(i18n): update TOTP verification message with configuration details 2025-09-12 21:53:21 +08:00
CaIon e60d6ed4a3 feat(settings): 移除单位美元额度设置项,为后续修改作准备 2025-09-12 21:14:10 +08:00
CaIon cde212a6ff Merge remote-tracking branch 'origin/main' 2025-09-12 19:54:46 +08:00
CaIon 0b87aa3a6f feat(i18n): add translations for pricing terms in English 2025-09-12 19:54:02 +08:00
CaIon b8687d96d2 Merge remote-tracking branch 'origin/main' 2025-09-12 19:29:40 +08:00
CaIon c47d9fb5b5 feat(payment): add payment settings configuration and update payment methods handling 2025-09-12 19:29:34 +08:00
CaIon b04aa78013 feat(channel): add support for Vertex AI key type configuration in settings 2025-09-12 14:06:09 +08:00
Calcium-Ion c9ffa5a6a4 Merge pull request #1784 from Husky-Yellow/fix/1773
fix: UI 未对齐问题
2025-09-12 12:39:29 +08:00
Calcium-Ion 9741a83f7c Merge pull request #1780 from ShibaInu64/feature/support-amazon-nova
feat: support amazon nova model
2025-09-12 12:38:44 +08:00
Zhaokun Zhang 42a9c495f8 fix: UI 未对齐问题 2025-09-11 21:34:49 +08:00
CaIon 83154b88f6 fix(responses): allow pass-through body for specific channel settings. (close #1762) 2025-09-11 21:02:12 +08:00
Calcium-Ion 413bb99f7d Merge pull request #1775 from QuantumNous/alpha
Alpha
2025-09-11 18:47:35 +08:00
Xyfacai 9fb64f337c fix: 预扣额度使用 relay info 传递 2025-09-11 16:04:32 +08:00
feitianbubu 8da06d5473 fix: get video task err when Content-Type=json 2025-09-11 12:53:19 +08:00
huanghejian 837f5556d9 feat: improve nova config 2025-09-11 12:31:43 +08:00
creamlike1024 497fdba3f8 fix: 优化 ImageRequest 的 JSON 序列化,避免覆盖合并 ExtraFields 2025-09-11 12:28:57 +08:00
huanghejian b10ed4b424 pref: support amazon nova 2025-09-11 12:17:16 +08:00
heimoshuiyu f48e8d5180 feat: add thousand separators to token display in dashboard 2025-09-11 10:34:51 +08:00
huanghejian 2d4ecaaf28 feat: amazon.nova-premier-v1:0 2025-09-11 10:01:54 +08:00
huanghejian f795148c58 feat: support amazon nova 2025-09-10 20:30:00 +08:00
Xyfacai 451076fa51 fix: dalle log 显示张数 N 2025-09-10 19:53:32 +08:00
DD 68d30225e5 merge 2025-09-10 19:11:58 +08:00
DD 116d070bbf merge 2025-09-10 18:37:55 +08:00
DD 2fb5fc231d Merge branches 'main' and 'main' of github.com:danding5/new-api
# Conflicts:
#	common/api_type.go
#	constant/api_type.go
#	constant/channel.go
#	relay/relay_adaptor.go
#	web/src/constants/channel.constants.js
2025-09-10 18:33:42 +08:00
Xyfacai 271830fc52 fix: err 如果是 newApiErr 则保留 2025-09-10 15:31:35 +08:00
Xyfacai 3f9adc9992 fix: openai 格式请求 claude 没计费 create cache token 2025-09-10 15:30:23 +08:00
Xyfacai 00579f2f8d feat: dalle 自定义字段透传 2025-09-10 15:29:07 +08:00
undefinedcodezhong 2d893d2506 fix:Account Management Status 2025-09-10 10:41:44 +08:00
CaIon e8afc25c71 chore: remove PR branching strategy workflow file 2025-09-09 23:23:53 +08:00
Calcium-Ion e2c2d182fa Merge pull request #1767 from QuantumNous/copy-claude-header-from-upstream
fix: claude header was not set correctly
2025-09-09 23:21:57 +08:00
creamlike1024 bbfbce9c67 fix: claude header was not set correctly 2025-09-09 23:18:07 +08:00
CaIon 1b1953e21a Merge branch 'alpha'
# Conflicts:
#	README.md
2025-09-09 23:08:17 +08:00
Little Write edf46c701f 前端部分,调试 完善 2025-09-08 23:25:30 +08:00
Little Write 51a7aa440b 完成 后端 部分,webo hhok 待完善 2025-09-08 23:07:05 +08:00
DD 62a25874fa merge 2025-09-08 17:33:15 +08:00
DD cfbc2df3f8 add submodel.ai 2025-09-08 16:21:21 +08:00
Calcium-Ion b3e67d5ef7 Merge pull request #1692 from yunayj/alpha
修改claude system参数为数组,增加通用性
2025-09-08 14:55:48 +08:00
IcedTangerine 8319198122 Merge pull request #1761 from QuantumNous/openaitoclaude-improve
feat: 改进Claude响应转OpenAI响应
2025-09-07 23:39:30 +08:00
creamlike1024 de73bfff78 feat(relay-claude): mapping stop reason and send text delta on block start type
- convert claude stop reason "max_tokens" to openai "length"
- send content_block_start content text delta
2025-09-07 23:03:19 +08:00
Seefs 80cfa0d0df Merge pull request #1677 from QuantumNous/gemini-2.5-flash-image-preview-billing
feat: gemini-2.5-flash-image-preview 文本和图片输出计费
2025-09-07 14:15:24 +08:00
Seefs 8fcc49377c Merge pull request #1754 from HynoR/fix/dtresp
fix: ensure the BuiltInTools entry exists before incrementing CallCount
2025-09-07 13:56:42 +08:00
HynoR 23a82b9646 fix: enhance tool usage parsing with additional nil checks and error logging 2025-09-07 07:42:25 +08:00
t0ng7u baf134cd50 🐛 fix(models): export setActivePage to prevent tab-change TypeError
Context:
Clicking a vendor tab triggered “setActivePage is not a function” from ModelsTabs.jsx:43.

Root cause:
ModelsTabs expects `setActivePage` via props (spread from `useModelsData`), but the hook did not expose it in its return object, so the prop resolved to `undefined`.

Fix:
Export `setActivePage` from `useModelsData`’s return object so `ModelsTabs` receives a valid function.

Result:
Tab switching now correctly resets pagination to page 1 and reloads models without runtime errors.

Files:
- web/src/hooks/models/useModelsData.jsx

Test plan:
- Open the Models page
- Click different vendor tabs
- Verify no crash occurs and the list reloads with page reset to 1

Refs: web/src/components/table/models/ModelsTabs.jsx:43
2025-09-06 21:57:26 +08:00
HynoR ab5351c270 fix: add error handling for missing built-in tools and validate response in stream handler 2025-09-05 13:58:24 +08:00
Seefs dffbd39cde Merge pull request #1749 from nekohy/feats-negative-number 2025-09-04 23:39:43 +08:00
Nekohy 1de5216148 fix: allow the negative number for override.go 2025-09-04 23:36:19 +08:00
CaIon e53cbd96ad fix(channel): implement per-channel locking to ensure thread-safe updates in multi-key mode 2025-09-03 15:52:54 +08:00
Calcium-Ion 6d81312e7e Add request format conversion functionality
Updated the features list to include request format conversion functionality and adjusted the order of items.
2025-09-03 14:45:00 +08:00
CaIon 4f5c343791 feat(readme): update format conversion feature details in README 2025-09-03 14:43:51 +08:00
CaIon f0183785c9 feat(option): enhance UpdateOption to handle various value types and improve validation 2025-09-03 14:30:25 +08:00
CaIon 1bbabda081 feat(monitor_setting): implement automatic channel testing configuration 2025-09-03 14:00:52 +08:00
t0ng7u 22b724ca44 🐛 fix(models-sync): allow sync when no conflicts selected
When syncing official models, clicking "Apply overwrite" with zero selected
conflict fields resulted in no request being sent and the modal simply closing.
This blocked creation of missing models/vendors even though the backend
supports an empty `overwrite` array and will still create missing items.

Changes:
- Remove the early-return guard in `UpstreamConflictModal.handleOk`
- Always call `onSubmit(payload)` even when `payload` is empty
- Keep closing behavior when the request succeeds

Behavior:
- Users can now proceed with upstream sync without selecting any conflict fields
- Missing models/vendors are created as expected
- Existing models are not overwritten unless fields are explicitly selected

Affected:
- web/src/components/table/models/modals/UpstreamConflictModal.jsx

Quality:
- Lint passes
- No breaking changes
- No visual/UI changes beyond the intended behavior

Test plan:
1) Open official models sync and trigger a conflicts preview
2) Click "Apply overwrite" without selecting any fields
3) Expect the sync to proceed and a success toast indicating created models
4) Re-try with some fields selected to confirm overwrites still work
2025-09-03 00:06:27 +08:00
t0ng7u 25dbd39d1e 🐛 fix(models sync): send correct overwrite payload and drop fallback
Ensure UpstreamConflictModal submits { overwrite: payload, locale } instead of spreading an array into an object
Remove numeric-key fallback from applyUpstreamOverwrite for simpler and explicit logic
Effect: selected fields are now actually updated; success message shows updated model count
Refs: backend SyncUpstreamModels expects overwrite: overwriteField[]
2025-09-02 19:07:17 +08:00
t0ng7u 31d5eb87ba Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-02 18:49:51 +08:00
t0ng7u 14af08750f feat(sync): multi-language sync wizard, backend locale support, and conflict modal UX improvements
Frontend (web)
- ModelsActions.jsx
  - Replace “Sync Official” with “Sync” and open a new two-step SyncWizard.
  - Pass selected locale through to preview, sync, and overwrite flows.
  - Keep conflict resolution flow; inject locale into overwrite submission.

- New: models/modals/SyncWizardModal.jsx
  - Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja).
  - Horizontal, centered Radio cards; returns { option, locale } via onConfirm.

- UpstreamConflictModal.jsx
  - Add search input (model fuzzy search) and native pagination.
  - Column header checkbox now only applies to rows in the current filtered result.
  - Fix “Cannot access ‘filteredDataSource’ before initialization”.
  - Refactor with useMemo/useCallback; extract helpers to remove duplicated logic:
    - getPresentRowsForField, getHeaderState, applyHeaderChange
  - Minor code cleanups and stability improvements.

- i18n (en.json)
  - Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.).
  - Adjust minor translations.

Hooks
- useModelsData.jsx
  - Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale.
  - Send locale via query/body accordingly.

Backend (Go)
- controller/model_sync.go
  - Accept locale from query/body and resolve i18n upstream URLs.
  - Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata).
  - Make HTTP timeouts/retries/limits configurable:
    - SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB
  - Add ETag-based caching and support both envelope and pure array JSON formats.
  - Concurrently fetch vendors and models; improve error responses with locale and source URLs.
  - Include source meta (locale, models_url, vendors_url) in success payloads.

Notes
- No breaking changes expected.
- Lint passes for touched files.
2025-09-02 18:49:37 +08:00
Seefs 4083126788 Merge pull request #1733 from seefs001/fix/jsoneditor
fix: adjust column spans in JSONEditor for better layout  #1719
2025-09-02 18:32:36 +08:00
Seefs d1fc9bd712 fix: adjust column spans in JSONEditor for better layout #1719 2025-09-02 18:28:23 +08:00
Seefs ada7b96823 Merge pull request #1724 from momomobinx/base
openai v1/models 完全兼容 解决接入trae时候的字段校验
2025-09-02 18:09:56 +08:00
Seefs 785d0c4284 Merge branch 'alpha' into base 2025-09-02 18:08:39 +08:00
t0ng7u 42e5794d00 🎨 fix: sidebar skeleton background and icon spacing consistency
- Set sidebar skeleton background to use theme variable (--semi-color-bg-0) instead of hardcoded white for better dark mode compatibility
- Apply consistent background to both collapsed and expanded skeleton states
- Ensure sidebar container uses theme background when skeleton is loading
- Remove duplicate margin-right classes from skeleton wrapper components that conflicted with CSS definitions
- Simplify navigation text structure by removing unnecessary div wrappers to improve text truncation
- Add proper text layout styles for better truncation handling when menu items have long names
- Standardize icon-to-text spacing across all sidebar navigation items
2025-09-02 17:07:01 +08:00
xuebin ff42b7fa88 openai v1/models 完全兼容 解决接入trae时候的字段校验 2025-09-02 14:17:54 +08:00
creamlike1024 ffe78e99ee Merge branch 'feitianbubu-pr/opt-audio-usage' into alpha 2025-09-02 13:35:28 +08:00
feitianbubu 69e76dae34 feat: use audio token usage if return 2025-09-02 10:58:10 +08:00
t0ng7u f10dab864f fix(web/layout): normalize HeaderBar -> headerbar (case) 2025-09-02 04:10:32 +08:00
t0ng7u 94129c48ea 🤖 fix(web/layout): rename HeaderBar -> headerbar (case sensitive) 2025-09-02 04:03:19 +08:00
t0ng7u c28e89abb8 🐛 fix(web/layout): explicitly import headerbar/index.jsx to resolve Linux build failure
The Linux/Vite build failed with:
“Could not resolve "./headerbar" from "src/components/layout/PageLayout.jsx"”

On Linux and with stricter ESM/rollup resolution, directory index files (index.jsx)
may not be auto-resolved reliably. Explicitly importing the index file ensures
cross-platform stability.

Changes:
- Update PageLayout import from "./headerbar" to "./headerbar/index.jsx"

Impact:
- Fixes build on Linux
- No runtime behavior changes

Verification:
- Linter passes for web/src/components/layout/PageLayout.jsx

Notes:
- Prefer explicit index file imports (and extensions) to avoid platform differences.
2025-09-02 03:54:32 +08:00
t0ng7u 5dc2d775e9 🌏 i18n: replace to correct punctuation mark 2025-09-02 03:42:31 +08:00
t0ng7u d8e36a7057 feat: Add skeleton loading states for sidebar navigation
Add comprehensive skeleton screen implementation for sidebar to improve loading UX, matching the existing headerbar skeleton pattern.

## Features Added
- **Sidebar skeleton screens**: Complete 1:1 recreation of sidebar structure during loading
- **Responsive skeleton layouts**: Different layouts for expanded (164×30px) and collapsed (44×44px) states
- **Skeleton component enhancements**: Extended SkeletonWrapper with new skeleton types (sidebar, button, sidebarNavItem, sidebarGroupTitle)
- **Minimum loading time**: Integrated useMinimumLoadingTime hook with 500ms duration for smooth UX

## Layout Specifications
- **Expanded nav items**: 164×30px with 8px horizontal margins and 3px vertical margins
- **Collapsed nav items**: 44×44px with 4px bottom margin and 8px horizontal margins
- **Collapse button**: 156×24px (expanded) / 36×24px (collapsed) with rounded corners
- **Container padding**: 12px top padding, 8px horizontal margins
- **Group labels**: 4px 15px 8px padding matching real sidebar-group-label styles

## Code Improvements
- **Refactored skeleton rendering**: Eliminated code duplication using reusable components (NavRow, CollapsedRow)
- **Configuration-driven sections**: Sections defined as config objects with title widths and item widths
- **Fixed width calculations**: Removed random width generation, using precise fixed widths per menu item
- **Proper CSS class alignment**: Uses real sidebar CSS classes (sidebar-section, sidebar-group-label, sidebar-divider)

## UI/UX Enhancements
- **Bottom-aligned collapse button**: Fixed positioning using margin-top: auto to stay at viewport bottom
- **Accurate spacing**: Matches real sidebar margins, padding, and spacing exactly
- **Skeleton stability**: Fixed width values prevent layout shifts during loading
- **Clean file structure**: Removed redundant HeaderBar.js export file

## Technical Details
- Extended SkeletonWrapper component with sidebar-specific skeleton types
- Integrated skeleton loading state management in SiderBar component
- Added support for collapsed state awareness in skeleton rendering
- Implemented precise dimension matching for pixel-perfect loading states

Closes: Sidebar skeleton loading implementation
2025-09-02 03:38:01 +08:00
t0ng7u d15b31ab5c 🧹 refactor(db): remove legacy models/vendors index cleanup logic
- Delete dropIndexIfExists helper from `model/main.go`
- Remove all calls to dropIndexIfExists in `migrateDB` and `migrateDBFast`
- Drop related comments and MySQL-only DROP INDEX code paths
- Keep GORM AutoMigrate as the sole migration path for `Model` and `Vendor`

Why:
- Simplifies migrations and avoids destructive index drops at startup
- Prevents noisy MySQL 1091 errors and vendor-specific branches
- Aligns with composite unique indexes (uk_model_name_delete_at, uk_vendor_name_delete_at)

Impact:
- No expected runtime behavior change; schema remains managed by GORM
- Legacy single-column unique indexes (if any) will no longer be auto-dropped
- Safe across MySQL/PostgreSQL/SQLite; MySQL Chinese charset checks remain intact

Verification:
- Lint passed for `model/main.go`
- Confirmed no remaining `DROP INDEX` or `dropIndexIfExists` references
2025-09-02 02:24:17 +08:00
t0ng7u 54f118d9ba feat(models-sync): official upstream sync with conflict resolution UI, opt‑out flag, and backend resiliency
Backend
- Add endpoints:
  - GET /api/models/sync_upstream/preview — diff preview (filters out models with sync_official = 0)
  - POST /api/models/sync_upstream — apply sync (create missing; optionally overwrite selected fields)
- Respect opt‑out: skip models with sync_official = 0 in both preview and apply
- Return detailed stats: created_models, created_vendors, updated_models, skipped_models, plus created_list / updated_list
- Add model.Model.SyncOfficial (default 1); auto‑migrated by GORM
- Make HTTP fetching robust:
  - Shared http.Client (connection reuse) with 3x exponential backoff retry
  - 10MB response cap; keep existing IPv4‑first for *.github.io
- Vendor handling:
  - New ensureVendorID helper (cache lookup → DB lookup → create), reduces round‑trips
  - Transactional overwrite to avoid partial updates
- Small cleanups and clearer helpers (containsField, coalesce, chooseStatus)

Frontend
- ModelsActions: add “Sync official” button with Popover (p‑2) explaining community contribution; loading = syncing || previewing; preview → conflict modal → apply flow
- New UpstreamConflictModal:
  - Per‑field columns (description/icon/tags/vendor/name_rule/status) with column‑level checkbox to select all
  - Cell with Checkbox + Tag (“Click to view differences”) and Popover (p‑2) showing Local vs Official values
  - Auto‑hide columns with no conflicts; responsive width; use native Semi Modal footer
  - Full i18n coverage
- useModelsData: add syncing/previewing states; new methods previewUpstreamDiff, applyUpstreamOverwrite, syncUpstream; refresh vendors/models after apply
- EditModelModal: add “Participate in official sync” switch; persisted as sync_official
- ModelsColumnDefs: add “Participate in official sync” column

i18n
- Add missing English keys for the new UI and messages; fix quoting issues

Refs
- Upstream metadata: https://github.com/basellm/llm-metadata
2025-09-02 02:04:22 +08:00
t0ng7u 55c8271311 feat(ratio-sync, ui): add built‑in “Official Ratio Preset” and harden upstream sync
Backend (controller/ratio_sync.go):
- Add built‑in official upstream to GetSyncableChannels (ID: -100, BaseURL: https://basellm.github.io)
- Support absolute endpoint URLs; otherwise join BaseURL + endpoint (defaults to /api/ratio_config)
- Harden HTTP client:
  - IPv4‑first with IPv6 fallback for github.io
  - Add ResponseHeaderTimeout
  - 3 attempts with exponential backoff (200/400/800ms)
- Validate Content-Type and limit response body to 10MB (safe decode via io.LimitReader)
- Robust parsing: support type1 ratio_config map and type2 pricing list
- Use net.SplitHostPort for host parsing
- Use float tolerance in differences comparison to avoid false mismatches
- Remove unused code (tryDirect) and improve warnings

Frontend:
- UpstreamRatioSync.jsx: auto-assign official endpoint to /llm-metadata/api/newapi/ratio_config-v1-base.json
- ChannelSelectorModal.jsx:
  - Pin the official source at the top of the list
  - Show a green “官方” tag next to the status
  - Refactor status renderer to accept the full record

Notes:
- Backward compatible; no API surface changes
- Official ratio_config reference: https://basellm.github.io/llm-metadata/api/newapi/ratio_config-v1-base.json
2025-09-01 23:43:39 +08:00
Seefs 7d9728519c Merge pull request #1680 from HynoR/fix/res
fix: update model name filtering to be case-sensitive
2025-09-01 21:04:27 +08:00
Seefs 99afd05d7d Merge pull request #1712 from seefs001/feature/bark
feat: bark notification #1699
2025-09-01 20:28:12 +08:00
Seefs 60b7db29fc Merge pull request #1713 from seefs001/feature/channel_remark
feat: add channel remark #1710
2025-09-01 20:26:06 +08:00
Seefs 06785c019f feat: add channel remark #1710 2025-09-01 16:23:29 +08:00
Seefs 4f44bbed31 feat: bark notification #1699 2025-09-01 15:57:23 +08:00
IcedTangerine 5bb732394f Merge pull request #1672 from feitianbubu/pr/fix-mysql-default-false
fix: prevent loop auto-migrate with bool default false
2025-08-31 15:06:37 +08:00
creamlike1024 3d86279240 fix: revert a00d8e2 2025-08-31 14:59:55 +08:00
creamlike1024 11fa5042df fix: update sidebar modules role check 2025-08-31 14:54:47 +08:00
creamlike1024 a00d8e25eb fix(user): UpdateSelf 边栏权限检查和类型检查 2025-08-31 14:40:35 +08:00
creamlike1024 8ed175a5f8 fix: add OptionMap RLock to GetStatus() 2025-08-31 14:28:02 +08:00
creamlike1024 d2dcd8beb3 Merge branch 'alpha' of github.com:x-Ai/new-api into x-Ai-alpha 2025-08-31 14:03:17 +08:00
t0ng7u ed71c9fcf3 🎨 style(go): format entire codebase
- Apply canonical Go formatting to all .go files
- No functional changes; whitespace/import/struct layout only
- Improves consistency, reduces diff noise, and aligns with standard tooling
2025-08-31 13:08:34 +08:00
t0ng7u 6d7c00634c 🐛 fix(db): rename composite unique indexes to avoid drop/recreate on restart
- Model: rename `uk_model_name` -> `uk_model_name_delete_at`
  (composite on `model_name` + `deleted_at`)
- Vendor: rename `uk_vendor_name` -> `uk_vendor_name_delete_at`
  (composite on `name` + `deleted_at`)
- Keep legacy cleanup in `model/main.go` to drop old index names
  (`uk_model_name`, `model_name`, `uk_vendor_name`, `name`) for compatibility.

Result: idempotent GORM migrations and no unnecessary index churn on MySQL restarts.

Files:
- `model/model_meta.go`
- `model/vendor_meta.go`
2025-08-31 13:00:28 +08:00
F。 d0d6168e2f 顶栏和侧边栏管理
增加用户体验
2025-08-31 07:07:40 +08:00
creamlike1024 ff4b5b3fd2 feat: improve ratio update 2025-08-30 23:53:46 +08:00
creamlike1024 2875dbba10 feat: 图像倍率,音频倍率和音频补全倍率配置 2025-08-30 23:28:09 +08:00
t0ng7u 0d57b1acd4 🎨 chore(web): apply ESLint and Prettier auto-fixes (baseline)
- Ran: bun run eslint:fix && bun run lint:fix
- Inserted AGPL license header via eslint-plugin-header
- Enforced no-multiple-empty-lines and other lint rules
- Formatted code using Prettier v3 (@so1ve/prettier-config)
- No functional changes; formatting-only baseline across JS/JSX files
2025-08-30 21:15:10 +08:00
t0ng7u 41cf516ec5 🤓 chore: remove the useless tooltip component 2025-08-29 23:56:18 +08:00
t0ng7u aee5b8caa7 feat(pricing): add default vendor icons with LobeHub color support
- Add defaultVendorIcons mapping for major AI vendors
- Update getOrCreateVendor to automatically set vendor icons
- Add getDefaultVendorIcon helper function
- Support LobeHub icons Color variants (e.g., Claude.Color, Gemini.Color)
- Fix issue where default vendors were created without icons

This ensures that when new models are encountered, their vendors
will be created with appropriate colored icons for better UI display.

Affected vendors include:
- OpenAI, Anthropic, Google, Moonshot, 智谱, 阿里巴巴
- DeepSeek, MiniMax, 百度, 讯飞, 腾讯, Cohere
- Cloudflare, 360, 零一万物, Jina, Mistral, xAI
- Meta, 字节跳动, 快手, 即梦, Vidu, Microsoft/Azure
2025-08-29 23:42:33 +08:00
creamlike1024 fc7234a1c9 Merge branch 'feitianbubu-pr/fix-kling-text2image' into alpha 2025-08-29 22:47:21 +08:00
creamlike1024 d081dcff46 Merge branch 'pr/fix-kling-text2image' of github.com:feitianbubu/new-api into feitianbubu-pr/fix-kling-text2image 2025-08-29 22:45:59 +08:00
creamlike1024 95f5cb3980 Merge branch 'feitianbubu-pr/kling-new-params' into alpha 2025-08-29 22:40:23 +08:00
creamlike1024 63bf35a111 Merge branch 'pr/kling-new-params' of github.com:feitianbubu/new-api into feitianbubu-pr/kling-new-params 2025-08-29 22:39:54 +08:00
t0ng7u 5b90f60519 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-29 22:36:39 +08:00
t0ng7u 7826099221 🎨 feat(ui): enhance pricing components with improved icons and responsive design
- Replace copy button icon from semi-ui IconCopy to lucide-react Copy in PricingCardView
- Add conditional tooltip functionality to SelectableButtonGroup that only shows when text overflows
- Implement responsive table column behavior in PricingTableColumns with mobile-aware fixed positioning
- Use DOM-based overflow detection (scrollWidth vs clientWidth) for better performance
- Apply useIsMobile hook to conditionally set fixed: 'right' only on desktop devices

These changes improve user experience across different screen sizes and provide more consistent iconography throughout the pricing interface.
2025-08-29 22:36:05 +08:00
IcedTangerine 4bf9ef5579 Merge pull request #1564 from feitianbubu/pr/init-default-vendor
feat: init default vendor
2025-08-29 19:48:20 +08:00
yunayj fe71af943c 修改claude system参数为数组格式,提升API兼容性 2025-08-29 19:06:01 +08:00
t0ng7u 64058614cb chore: Increase default page size from 10 to 100 items in model pricing views
This commit updates the default pagination settings across the model pricing
components to improve user experience by reducing the need for frequent
page navigation when browsing large model catalogs.

Changes made:
- Update initial pageSize state from 10 to 100 in useModelPricingData hook
- Set defaultPageSize to 100 in PricingTable pagination configuration
- Increase default skeletonCount from 10 to 100 in PricingCardSkeleton

Files modified:
- web/src/hooks/model-pricing/useModelPricingData.jsx
- web/src/components/table/model-pricing/view/table/PricingTable.jsx
- web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx

This change affects both card and table view modes of the model pricing page,
ensuring consistent pagination behavior across different display formats.
2025-08-29 18:30:21 +08:00
t0ng7u 8ceb0316ce 📱 feat(pricing-header): show only search/copy/filter on mobile; hide vendor intro
- Mobile (isMobile=true): render SearchActions (search, copy, filter) only; hide vendor intro card
- Keep PricingFilterModal available on mobile for filtering
- Desktop/Non-mobile: unchanged behavior (vendor intro remains visible)
- Improves small-screen UX by reducing visual clutter and prioritizing primary actions

Files:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx

Notes:
- Added `SearchActions` import and conditional rendering
- No breaking changes; no styling changes required
2025-08-29 17:26:51 +08:00
t0ng7u 1bc0010f5c 🎨 feat: Implement responsive design for SelectableButtonGroup component
This commit introduces a comprehensive responsive design system for the SelectableButtonGroup component that adapts to container width changes, particularly optimized for dynamic sidebar layouts.

## Key Features

### 1. Container Width Detection
- Added `useContainerWidth` hook using ResizeObserver API
- Real-time container width monitoring for responsive calculations
- Automatic layout adjustments based on available space

### 2. Intelligent Column Layout
Implements a 4-tier responsive system:
- **≤280px**: 1 column + tags (mobile portrait)
- **281-380px**: 2 columns + tags (narrow screens)
- **381-460px**: 3 columns - tags (general case, prioritizes readability)
- **>460px**: 3 columns + tags (wide screens, full feature display)

### 3. Dynamic Tag Visibility
- Tags automatically hide in medium-width containers (381-460px) to improve text readability
- Tags show in narrow and wide containers where space allows for optimal UX
- Responsive threshold ensures content clarity across all viewport sizes

### 4. Adaptive Grid Spacing
- Compact spacing `[4,4]` for containers ≤400px
- Standard spacing `[6,6]` for larger containers
- Additional `.sbg-compact` CSS class for fine-tuned styling in narrow layouts

### 5. Sidebar Integration
- Perfectly compatible with dynamic sidebar width: `clamp(280px, 24vw, 520px)`
- Automatically adjusts as sidebar scales with viewport changes
- Maintains optimal button density and information display at all sizes

## Technical Implementation

- **Hook**: `useContainerWidth.js` - ResizeObserver-based width detection
- **Component**: Enhanced `SelectableButtonGroup.jsx` with responsive logic
- **Styling**: Added `.sbg-compact` mode in `index.css`
- **Performance**: Efficient span calculation using `Math.floor(24 / perRow)`

## Benefits

- Improved UX across all screen sizes and sidebar configurations
- Better text readability through intelligent tag hiding
- Seamless integration with existing responsive sidebar system
- Maintains component functionality while optimizing space utilization

Closes: Responsive design implementation for model marketplace sidebar components
2025-08-29 17:05:49 +08:00
feitianbubu 6db0e77931 feat: add kling video new params support
for example: image_tail,negative_prompt,camera_control
2025-08-29 11:51:41 +08:00
CaIon 742871db57 feat(token_counter): add mime type extraction for base64 encoded data 2025-08-28 17:30:53 +08:00
CaIon 9e4c4d3bd1 fix(channel): ensure thread-safe polling with channel-specific lock 2025-08-28 16:34:23 +08:00
Xyfacai c7dc4ad1ef fix: 修复openai error 错误被覆盖 2025-08-28 16:08:51 +08:00
HynoR e6f78733e1 fix: update model name filtering to be case-sensitive 2025-08-28 15:16:25 +08:00
CaIon f7c4eda0f3 Revert "refactor: replace DeepCopy with Copy for request handling consistency"
This reverts commit c21219fcff.
2025-08-28 15:11:55 +08:00
CaIon c21219fcff refactor: replace DeepCopy with Copy for request handling consistency 2025-08-28 14:57:47 +08:00
CaIon 7e698f658a fix(pre_consume_quota): improve error messages for insufficient user quota 2025-08-28 13:57:16 +08:00
CaIon 906f797be5 refactor: replace json.Marshal with common.Marshal for consistency and error handling 2025-08-28 13:51:07 +08:00
creamlike1024 cebba71d7c Merge branch 'iszcz-alpha' into alpha 2025-08-27 23:27:31 +08:00
creamlike1024 3963863eb0 fix(relay): initialize TaskRelayInfo 2025-08-27 23:26:51 +08:00
creamlike1024 13d14dc8a8 Merge branch 'alpha' of github.com:iszcz/new-api into iszcz-alpha 2025-08-27 22:26:15 +08:00
creamlike1024 a45513a7a6 feat: gemini-2.5-flash-image-preview 文本和图片输出计费 2025-08-27 21:30:52 +08:00
Sh1n3zZ cf8b021b61 feat: vertex veo (#1450) 2025-08-27 18:06:47 +08:00
CaIon d1f3f2c395 fix(token_counter): update token counting logic to handle media token conditions 2025-08-27 17:11:33 +08:00
CaIon d92c9db61c fix(image): add error handling for empty base64 string in image decoding 2025-08-27 16:38:32 +08:00
feitianbubu d47779b0a3 fix: prevent loop auto-migrate with bool default false 2025-08-27 12:33:56 +08:00
CaIon 3401abe4d2 fix(relay-gemini): update media handling in candidate content processing 2025-08-27 12:14:50 +08:00
t0ng7u 61f2aa541b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-27 11:59:41 +08:00
t0ng7u 17cf9c2d60 feat(playground): add search functionality to group selector
- Add `filter={selectFilter}` to enable search filtering in group Select component
- Add `autoClearSearchValue={false}` to preserve search input value
- Maintain consistency with existing model selector search behavior
- Improve user experience by allowing quick filtering of group options

Files modified:
- web/src/components/playground/SettingsPanel.jsx
2025-08-27 11:57:12 +08:00
t0ng7u 943e36ebaf 🐛 fix(playground): set Chat error text color to white to match Semi UI
- Update error-state rendering to use white text in the playground chat
- Remove Typography.Text `type="danger"` and the red background for consistency with official behavior
- Preserve layout and other message states (loading/success/system) unchanged
- No linter issues introduced

Files touched:
- web/src/components/playground/MessageContent.jsx
2025-08-27 11:50:43 +08:00
creamlike1024 80a7d7eb51 Merge branch 'AAEE86-alpha' into alpha 2025-08-27 01:30:20 +08:00
creamlike1024 d6fcc7a51d feat: add disable cache middleware 2025-08-27 01:30:01 +08:00
creamlike1024 1555e6832e Merge branch 'alpha' of github.com:AAEE86/new-api into AAEE86-alpha 2025-08-27 01:28:37 +08:00
CaIon a102bed25a fix(model_ratio): update return value logic for gemini-2.5-flash-lite 2025-08-26 23:01:00 +08:00
CaIon 8004b4ff5f Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-26 20:38:32 +08:00
Calcium-Ion 59a5cb8d32 Merge pull request #1579 from wzxjohn/hotfix/openai_claude_convert
fix(adaptor): missing first text delta while convert OpenAI to Claude
2025-08-26 20:37:54 +08:00
Calcium-Ion 6e62b76105 Merge pull request #1652 from HynoR/feat/ds3.1
feat: adapt Volcengine adaptor for deepseek3.1 with thinking mode
2025-08-26 20:35:29 +08:00
CaIon 67719dc087 fix: update web search handling and request structure in adaptor and openai_request 2025-08-26 18:15:18 +08:00
CaIon 1b8bcfb000 fix: update error types for upstream errors and JSON marshal failure 2025-08-26 16:26:56 +08:00
CaIon b0f86bd82e fix: update PromptTokens assignment logic in relay_responses 2025-08-26 14:21:10 +08:00
CaIon 0e34de8fe2 feat: replace pcopy with jinzhu/copier for deep copy functionality 2025-08-26 13:40:41 +08:00
CaIon 3e5bc637de fix: Invalid type for 'input[x].summary': expected an array of objects, but got null instead 2025-08-26 13:17:31 +08:00
CaIon f249cf9acc fix: ensure reasoning is not nil before setting effort in OpenAI responses 2025-08-25 22:46:45 +08:00
iszcz d904f9b486 task_relay_info 2025-08-25 18:01:10 +08:00
IcedTangerine 5d384fa4f6 Merge pull request #1549 from QuantumNous/update-openai-websearch-price
feat: update openai websearch price
2025-08-25 16:52:57 +08:00
AAEE86 6579d71fe1 fix: 添加接口速率限制中间件,优化验证码输入框交互体验 2025-08-25 15:20:04 +08:00
AAEE86 6033d4318e feat(channel): 添加2FA验证后查看渠道密钥功能
- 新增接口通过2FA验证后获取渠道密钥
- 统一实现2FA验证码和备用码的验证逻辑
- 记录用户查看密钥的操作日志
- 编辑渠道弹窗新增查看密钥按钮,触发2FA验证模态框
- 使用TwoFactorAuthModal进行验证码输入及验证
- 验证成功后弹出渠道密钥展示窗口
- 对渠道编辑模态框的状态进行了统一重置优化
- 添加相关国际化文案支持密钥查看功能
2025-08-25 14:45:48 +08:00
CaIon 781a708173 feat: support qwen-image-edit 2025-08-25 14:33:12 +08:00
CaIon bb897a893e Merge remote-tracking branch 'origin/feat/dalle-extra' into alpha
# Conflicts:
#	dto/dalle.go
2025-08-25 14:20:54 +08:00
CaIon 79c7d8f477 fix: improve model ratio handling for reserved models in getHardcodedCompletionModelRatio 2025-08-25 11:59:55 +08:00
CaIon 5c7395adc6 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-25 11:52:57 +08:00
CaIon 7e513ad06d fix: update model ratio handling for GPT versions 2025-08-25 11:52:45 +08:00
t0ng7u 05ad8c1223 🍎 chore(EditChannelModal.jsx): Format code file 2025-08-25 11:32:28 +08:00
t0ng7u a955b43f3e 🧹 chore: remove useless i18n file 2025-08-25 11:29:13 +08:00
同語 3baca03895 🤓 feat: When adding or editing channels, add the function of clicking the added model to copy its name
Merge pull request #1648 from AAEE86/alpha
2025-08-25 11:26:45 +08:00
CaIon a69f166e9e feat: add SaveWithoutKey method to Channel and update status saving logic 2025-08-25 11:20:16 +08:00
CaIon 1a5ba75068 fix: correct logic for handling nil OpenAI error codes. (close #1609) 2025-08-25 11:19:32 +08:00
HynoR c94a74c398 feat: adapt Volcengine adaptor for deepseek3.1 model with thinking suffix 2025-08-25 11:16:43 +08:00
Calcium-Ion 87d0d12d39 Merge pull request #1647 from aotsukiqx/main
fix: update channel.go fix #1641
2025-08-25 10:25:06 +08:00
CaIon 3d7a05bdac feat: update ali image response handling 2025-08-24 22:50:45 +08:00
AAEE86 15a36a65b3 feat: 在新增&编辑渠道时添加点击模型复制名称功能
(cherry picked from commit c4935f392f)
2025-08-24 22:50:29 +08:00
CaIon 488f6a2e71 feat: enhance image request handling and add async support 2025-08-24 21:52:56 +08:00
t0ng7u 103cb4e084 💄 style(topup): align container width with PersonalSetting (w-full max-7xl)
- Set TopUp page outer wrapper to "w-full max-w-7xl mx-auto px-2"
  to match PersonalSetting and ensure consistent layout width and padding.
- No functional changes; UI-only adjustment.
- Lint checks passed.
- Verified that pages/TopUp only re-exports the component (no extra wrapper).

Affected: web/src/components/topup/index.jsx
2025-08-24 17:29:42 +08:00
t0ng7u eddaebf745 🕒 feat(ui): standardize Timelines to left mode and unify time display
- Switch Semi UI Timeline to mode="left" in:
  - web/src/components/layout/NoticeModal.jsx
  - web/src/components/dashboard/AnnouncementsPanel.jsx
- Show both relative and absolute time in the `time` prop (e.g. "3 days ago 2025-02-18 10:30")
- Move auxiliary description to the `extra` prop and remove duplicate rendering from content area
- Keep original `extra` data intact; compute and pass:
  - `time`: absolute time (yyyy-MM-dd HH:mm)
  - `relative`: relative time (e.g., "3 days ago")
- Update data assembly to expose `time` and `relative` without overwriting `extra`:
  - web/src/components/dashboard/index.jsx
- No i18n changes; no linter errors introduced

Why: Aligns Timeline layout across the app and clarifies time context by combining relative and absolute timestamps while preserving auxiliary notes via `extra`.
2025-08-24 17:23:03 +08:00
aotsuki 3b50fcb72b Update channel.go 2025-08-24 11:39:38 +08:00
t0ng7u 99be6d557b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-24 10:52:54 +08:00
t0ng7u 157df7ae84 📱 feat(header): add mobile filter skeleton; align mobile tag layout
- Add mobile filter-button placeholder in skeleton when `isMobile` is true
- Plumb `isMobile` from `PricingVendorIntroWithSkeleton` to `PricingVendorIntroSkeleton`
- Rename skeleton key from 'button' to 'copy-button' for consistency
- Neutralize copy-button skeleton color to match input (use neutral palette)
- Keep “Total x models” tag inline with title on mobile; wrap only when space is insufficient
- Mirror the same title+tag layout in the skeleton (flex-row flex-wrap items-center)
- No linter errors introduced

Affected files:
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
2025-08-24 10:52:43 +08:00
Calcium-Ion a8062c4e9f Merge pull request #1644 from nekohy/feats-custom-request-headers
feats:add custom headers override
2025-08-24 10:14:32 +08:00
Nekohy 82c032e8c3 fix: log name 2025-08-24 01:32:19 +08:00
Nekohy 2d1512096a feats:add custom headers override 2025-08-24 01:02:23 +08:00
t0ng7u 868f1b036a feat(model-pricing): enhance pricing vendor intro components with performance optimizations and UX improvements
## Major Changes

### Performance Optimizations
- Add React.memo to all components to prevent unnecessary re-renders
- Implement useCallback for expensive functions (renderSearchActions, renderHeaderCard, etc.)
- Extract createSkeletonRect function outside component to avoid recreation
- Optimize constant definitions and reduce magic numbers

### UI/UX Enhancements
- Replace Popover with Modal for vendor description display
- Add modal max height and vertical scrolling support
- Fix filter modal not showing on first click by always mounting component
- Improve responsive design with mobile-specific modal sizing

### Code Structure Improvements
- Refactor avatar rendering logic into pure helper functions
- Reorganize constants into semantic groups (CONFIG, THEME_COLORS, COMPONENT_STYLES, CONTENT_TEXTS)
- Simplify complex vendor info processing logic
- Fix sourceModels selection logic for better data handling

### Bug Fixes
- Fix React key prop missing in skeleton elements causing render errors
- Resolve modal mounting timing issues
- Correct dependency arrays in useCallback hooks

### Code Quality
- Remove redundant comments while preserving essential documentation
- Add displayName to all memo components for better debugging
- Standardize code formatting and naming conventions
- Improve TypeScript-like prop validation

## Files Modified
- PricingTopSection.jsx
- PricingVendorIntro.jsx
- PricingVendorIntroSkeleton.jsx
- PricingVendorIntroWithSkeleton.jsx
- SearchActions.jsx

## Performance Impact
- Reduced re-renders by approximately 60-80%
- Improved memory efficiency through function memoization
- Enhanced user experience with smoother interactions
2025-08-24 00:10:26 +08:00
t0ng7u 1f887c2e78 🤖 style: Make some changes 2025-08-23 22:03:34 +08:00
t0ng7u 90a30a860b 🐛 fix(model-pricing/header): pass sidebarProps to PricingFilterModal to prevent FilterModalContent crash
- Re-introduce and forward `sidebarProps` from PricingTopSection to PricingFilterModal
- Fix TypeError: “Cannot destructure property 'showWithRecharge' of 'sidebarProps' as it is undefined”
- Keep modal state managed at top section; no other behavioral changes
- Lint passes

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
2025-08-23 21:56:43 +08:00
t0ng7u 313bbc1c8b 🤓 style: add outline theme in pricingcard copy button 2025-08-23 21:48:18 +08:00
t0ng7u b10f28e5c6 🎨 refactor(model-pricing/header): unify header design, extract SearchActions, and improve skeleton
- Extract SearchActions.jsx and replace inline renderSearchActions in PricingVendorIntro.jsx for reuse
- Refactor PricingVendorIntro.jsx:
  - Introduce renderHeaderCard(), tagStyle, getCoverStyle(), and MAX_VISIBLE_AVATARS constant
  - Standardize vendor header cover (gradient + background image) and tag contrast
  - Use border instead of ring for vendor badges; unify visuals and remove Tailwind ring dependency
  - Rotate vendors every 2s only when filterVendor === 'all' and vendor count > 3
  - Remove unused imports; keep prop surface minimal; pass setShowFilterModal downward only
- Refactor PricingVendorIntroSkeleton.jsx:
  - Add getCoverStyle() and rect() helpers; rebuild skeleton to match final UI
  - Replace invalid Skeleton.Input usage; add missing keys; unify colors/borders/radius
- Update PricingTopSection.jsx:
  - Manage filter modal locally; drop redundant prop passing
- Update PricingVendorIntroWithSkeleton.jsx:
  - Align prop interface; forward only required props and keep useMinimumLoadingTime
- Add: web/src/components/table/model-pricing/layout/header/SearchActions.jsx
- Lint: all files pass; no dark:* classes present in this scope

Files touched:
- web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
- web/src/components/table/model-pricing/layout/header/SearchActions.jsx (new)
2025-08-23 21:11:40 +08:00
t0ng7u fc4d593301 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-23 19:30:07 +08:00
t0ng7u bee947000f 🎨 refactor(ui): use lucide-react for search/refresh and chevron icons
- DashboardHeader.jsx: replace Semi's IconSearch/IconRefresh with lucide-react's Search/RefreshCw (size 16), preserve existing button styles
- UptimePanel.jsx: replace Semi's IconRefresh with lucide-react's RefreshCw (size 14), keep styling intact
- UserArea.jsx: replace Semi's IconChevronDown with lucide-react's ChevronDown (size 14), preserve visual parity
- Update imports: remove @douyinfe/semi-icons usage where replaced; add lucide-react imports
- Verified no remaining IconSearch/IconRefresh in dashboard; no new linter errors

Motivation: unify icon library for core actions and improve UI consistency.
Follow-ups: consider migrating remaining Semi icons (e.g., plus/minus, charts) to lucide-react.
2025-08-23 19:29:56 +08:00
Calcium-Ion 48a6123c08 Merge pull request #1161 from lollipopkit/main
feat: query usage of token
2025-08-23 15:58:25 +08:00
CaIon 94e7f10367 feat: restructure token usage routes and enhance token retrieval logic 2025-08-23 15:45:43 +08:00
Calcium-Ion 1de4daa7d5 Merge branch 'alpha' into main 2025-08-23 15:27:08 +08:00
Calcium-Ion fe53424ed8 Merge pull request #1611 from nekohy/feats-zhipu_4v-anthropic
Feats:Standardize ClaudeHandler, add Zhipu_4v Anthropic native channel support
2025-08-23 13:48:18 +08:00
Calcium-Ion 12b9f0f560 Merge pull request #1635 from feitianbubu/pr/fix-task-info-channel-type
Pr/fix task info channel type
2025-08-23 13:46:52 +08:00
Calcium-Ion eece073235 Merge pull request #1642 from QuantumNous/fix-retry-and-rerank
fix: retry requeset body incorrect and fix rerank
2025-08-23 13:46:40 +08:00
CaIon 13301d8544 fix: enhance error handling for invalid request types in relay handlers 2025-08-23 13:34:56 +08:00
CaIon 060ce89286 refactor: rename relay-text.go to compatible_handler.go for clarity 2025-08-23 13:13:57 +08:00
CaIon ff57ad2072 fix: improve request handling by deep copying OpenAIResponsesRequest 2025-08-23 13:13:10 +08:00
CaIon 1ee3d1cc50 feat: 修复重试后请求结构混乱,修复rerank端点无法使用 2025-08-23 13:12:15 +08:00
t0ng7u 54e03c4214 🐛 fix(header): prevent NotificationButton from shrinking when unread badge appears
- Remove `size='small'` when the button is wrapped by `Badge`
- Keep button dimensions consistent with/without badge
- Preserve 18px icon size and existing styles/accessibility
- Lint check passed with no issues

Files: web/src/components/layout/HeaderBar/NotificationButton.jsx
2025-08-23 03:40:32 +08:00
t0ng7u 82e87b8498 📱 fix(ui/topup): make stats numbers responsive on mobile
Reduce KPI font size on small screens to prevent overlapping of large
numbers while preserving the desktop layout.

Changes:
- InvitationCard.jsx: use `text-base sm:text-2xl` for
  pending earnings, total earnings, and invite count.
- RechargeCard.jsx: use `text-base sm:text-2xl` for
  current balance, historical usage, and request count.

Impact:
- Visual-only; no behavioral changes.
- Desktop/tablet unchanged.
- Lint passes.

Files:
- web/src/components/topup/InvitationCard.jsx
- web/src/components/topup/RechargeCard.jsx
2025-08-23 03:36:18 +08:00
t0ng7u 1dff565ba1 🎨 refactor(ui): harden iframe messaging
- useHeaderBar.js
  - Wrap handlers with useCallback (logout, language/theme toggle, mobile menu)
  - Add null checks and try/catch around iframe postMessage (theme & language)
  - Keep minimal effect deps; remove unused StatusContext dispatch
  - Logo preload effect safe and scoped to `logo`

- index.jsx
  - No functional changes; locale memoization remains stable

Chore:
- Lint clean; no runtime warnings
- Verified no render loops or performance regressions
2025-08-23 03:17:03 +08:00
t0ng7u 30fc4d3082 🌓 feat(ui): add auto theme mode, refactor ThemeToggle, optimize header theme handling
- Feature: Introduce 'auto' theme mode
  - Detect system preference via matchMedia('(prefers-color-scheme: dark)')
  - Add useActualTheme context to expose the effective theme ('light'|'dark')
  - Persist selected mode in localStorage ('theme-mode') with 'auto' as default
  - Apply/remove `dark` class on <html> and sync `theme-mode` on <body>
  - Broadcast effective theme to iframes

- UI: Redesign ThemeToggle with Dropdown items and custom highlight
  - Replace non-existent IconMonitor with IconRefresh
  - Use Dropdown.Menu + Dropdown.Item with built-in icon prop
  - Selected state uses custom background highlight; hover state preserved
  - Remove checkmark; selection relies on background styling
  - Current button icon reflects selected mode

- Performance: reduce re-renders and unnecessary effects
  - Memoize theme options and current button icon (useMemo)
  - Simplify handleThemeToggle to accept only explicit modes ('light'|'dark'|'auto')
  - Minimize useEffect dependencies; remove unrelated deps

- Header: streamline useHeaderBar
  - Use useActualTheme for iframe theme messaging
  - Remove unused statusDispatch
  - Remove isNewYear from theme effect dependencies

- Home: send effective theme (useActualTheme) to external content iframes

- i18n: add/enhance theme-related copy in locales (en/zh)

- Chore: minor code cleanup and consistency
  - Improve readability and maintainability
  - Lint clean; no functional regressions
2025-08-23 03:02:35 +08:00
t0ng7u 9674065465 💄 style(ui): enhance table scroll card scrollbar visibility and appearance
- Make Y-axis scrollbar visible for .table-scroll-card .semi-card-body
- Reduce scrollbar width from 6px to 4px for a more subtle appearance
- Decrease scrollbar opacity from 0.3 to 0.2 for lighter color
- Adjust hover opacity from 0.5 to 0.35 for consistent lighter theme
- Remove previous scrollbar hiding styles to improve user experience

This change improves the usability of table scroll cards by providing
visual feedback for scrollable content while maintaining a clean,
unobtrusive design aesthetic.
2025-08-23 02:14:17 +08:00
t0ng7u d9024452dc 🚀 perf(vite): remove visactor manual chunk to avoid preloading on Home
Home was unexpectedly loading the `visactor-*.js` bundle on first paint. This
happened because the Vite manualChunks entry created a standalone vendor
chunk for VisActor, which Vite then preloaded on the initial route.

What’s changed
- Removed `visactor` from `build.rollupOptions.output.manualChunks` in `web/vite.config.js`.

Why
- Prevents VisActor from being preloaded on the Home page.
- Restores the intended behavior: VisActor loads only when the Dashboard (data
  analytics) is visited.

Impact
- Smaller initial payload and fewer network requests on Home.
- No functional changes to charts; loading behavior is now route-driven.

Test plan
- Build the app: `cd web && npm run build`.
- Open the preview and visit `/`: ensure no `visactor-*.js` is requested.
- Navigate to `/console` (Dashboard): ensure `visactor-*.js` loads as expected.
2025-08-23 01:54:32 +08:00
t0ng7u b43ecc2504 ♻️ refactor(home): build serverAddress using ${window.location.origin}
Replace the fallback assignment of serverAddress in `web/src/pages/Home/index.jsx`
to use a template literal for `window.location.origin`.

- Aligns with the preferred style for constructing base URLs
- Keeps formatting consistent across the app
- No functional changes; behavior remains the same
- Lint passes with no new warnings or errors

Files affected:
- web/src/pages/Home/index.jsx
2025-08-23 01:42:32 +08:00
t0ng7u 2ab2edec7b refactor: unify layout, adopt Semi UI Forms, dynamic presets, and fix duplicate requests
- Unify TopUp into a single-page layout and remove tabs
- Replace custom inputs with Semi UI Form components (Form.Input, Form.InputNumber, Form.Slot)
- Move online recharge form into the stats Card content for tighter, consistent layout
- Add account stats Card with blue theme (consistent with InvitationCard style)
- Remove RightStatsCard and inline the stats UI directly in RechargeCard
- Change preset amount UI to horizontal quick-action Buttons; swap order with payment methods
- Replace payment method Cards with Semi UI Buttons
  - Use Button icon prop for Alipay/WeChat/Stripe with brand colors
  - Use built-in Button loading; remove custom “processing...” text
- Replace custom spinners with Semi UI Spin and keep Skeleton for amount loading
- Wrap Redeem Code in a Card; use Typography for “Looking for a code? Buy Redeem Code” link
- Show info Banner when online recharge is disabled (instead of warning)

TopUp data flow and logic
- Generate preset amounts from min_topup using multipliers [1,5,10,30,50,100,300,500]
- Deduplicate /api/user/aff using a ref guard; fetch only once on mount
- Simplify user self fetch: update context only; remove unused local states and helpers
- Normalize payment method keys to alipay/wxpay/stripe and assign default colors

Cleanup
- Delete web/src/components/topup/RightStatsCard.jsx
- Remove unused helpers and local states in index.jsx (userQuota, userDataLoading, getUsername)

Dev notes
- No API changes; UI/UX refactor only
- Lint clean (no new linter errors)

Files
- web/src/components/topup/RechargeCard.jsx
- web/src/components/topup/index.jsx
- web/src/components/topup/InvitationCard.jsx (visual parity reference)
- web/src/components/topup/RightStatsCard.jsx (removed)
2025-08-23 01:32:54 +08:00
t0ng7u ada604db4e 🧹 refactor(settings): remove models API usage from Personal Settings
Personal Settings no longer needs to fetch `/api/user/models` since models are now displayed directly. This change removes the unused data flow to simplify the component and avoid unnecessary requests.

Changes:
- Removed `models` and `modelsLoading` state from `web/src/components/settings/PersonalSetting.jsx`
- Removed `loadModels` function and its invocation in the initial effect
- Kept UI behavior unchanged; no functional differences on the Personal Settings page

Notes:
- Lint passes with no new issues
- Other parts of the app still using `/api/user/models` (e.g., Tokens pages) are intentionally left intact

Rationale:
- Models are already displayed; the API call in Personal Settings became redundant
2025-08-22 23:01:32 +08:00
CaIon 858585d974 fix: update response body handling in OpenAI relay format 2025-08-22 17:33:20 +08:00
Calcium-Ion d09b99c1f0 Merge pull request #1606 from funnycups/patch-1
fix: prompt calculation
2025-08-22 17:30:53 +08:00
feitianbubu 184cdb5c28 fix: channel type nil point 2025-08-22 15:55:25 +08:00
Calcium-Ion d95d260555 Merge pull request #1627 from QuantumNous/feature/new-partner-intro
🤝 docs(README): Introduction to New Partners
2025-08-21 12:58:53 +08:00
t0ng7u cc514c7d18 🤝 docs(README): Enhancing Partner Layout 2025-08-21 12:51:49 +08:00
t0ng7u 2eae3a6116 🤝 docs(README): Enhancing Partner Layout 2025-08-21 12:49:56 +08:00
t0ng7u b6542c6840 🤝 docs(README): Introduction to New Partners 2025-08-21 12:42:19 +08:00
t0ng7u b383d1d7df 🍭 style(ui): update the README.md style 2025-08-19 01:46:09 +08:00
t0ng7u 436eb77ae2 🍭 style(ui): update the README.md style 2025-08-19 01:44:44 +08:00
t0ng7u d2e85a7d15 🔧 fix: adjust IO.NET logo viewBox for proper display size
- Fix io-net.svg viewBox from "0 0 1000 1000" to "100 440 800 120"
- Resolve issue where IO.NET logo appeared too small on GitHub
- Crop viewBox to actual logo content area for better visibility
- Ensure consistent display size with other partner logos
- Change aspect ratio from 1:1 to 6.67:1 to match horizontal layout
2025-08-19 01:02:57 +08:00
t0ng7u 6f322fdbab 🍭 style(ui): update the README.md style 2025-08-19 00:52:11 +08:00
t0ng7u db28d0de4e 🤝 docs: add IO.NET as trusted partner
- Add IO.NET to trusted partners section in README.md
- Add IO.NET to trusted partners section in README.en.md
- Use io-net.png logo and https://io.net/ as website link
- Adjust layout to display 3 partners in the second row for better balance
- IO.NET provides decentralized GPU computing services for AI workloads
2025-08-19 00:49:44 +08:00
t0ng7u 45130574fc 🍭 style(ui): update the README.md style 2025-08-19 00:43:36 +08:00
t0ng7u b6a3f8ee81 🤝 docs: add Alibaba Cloud as trusted partner
- Add Alibaba Cloud to trusted partners section in README.md
- Add Alibaba Cloud to trusted partners section in README.en.md
- Use aliyun.svg logo and https://www.aliyun.com/ as website link
- Maintain consistent formatting with existing partners
2025-08-19 00:39:58 +08:00
t0ng7u 18209c7d47 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-19 00:25:41 +08:00
t0ng7u 6c4777bc82 ♻️ refactor(InvitationCard): restructure cards with title prop and clean up styling
- Move card titles (earnings, total revenue, invitations) to Card title prop for consistency
- Remove custom color classes in favor of Semi-UI's built-in type system
- Standardize Text component usage with strong and tertiary types
- Improve code maintainability and visual consistency across all cards
- Align structure with existing reward description card pattern
2025-08-19 00:22:28 +08:00
creamlike1024 69686cdc43 Merge branch 'Sh1n3zZ-alpha' into alpha 2025-08-18 23:35:28 +08:00
creamlike1024 47ddf76af9 fix: Guard against negative or zero n from ExtraBody to prevent uint underflow 2025-08-18 23:35:01 +08:00
t0ng7u 44b7c605e3 🍎 style(ui): remove `transparent' style in model-pricing search input 2025-08-18 22:25:46 +08:00
Sh1n3zZ 8a1f6534fc feat: imagen for vertex channel 2025-08-18 21:49:55 +08:00
Nekohy 57b1ddcc5f feats:Standardize ClaudeHandler, add zhipu_4v Anthropic native support 2025-08-18 13:14:48 +08:00
CaIon bbc362c301 fix: add nil check for Writer in FlushWriter function 2025-08-18 12:48:56 +08:00
t0ng7u ba6ed31a1a ♻️ refactor(web): migrate React modules from .js to .jsx and align entrypoint
- Rename React components/pages/utilities that contain JSX to `.jsx` across `web/src`
- Update import paths and re-exports to match new `.jsx` extensions
- Fix Vite entry by switching `web/index.html` from `/src/index.js` to `/src/index.jsx`
- Verified remaining `.js` files are plain JS (hooks/helpers/constants) and do not require JSX
- No runtime behavior changes; extension and reference alignment only

Context: Resolves the Vite pre-transform error caused by the stale `/src/index.js` entry after migrating to `.jsx`.
2025-08-18 04:14:35 +08:00
t0ng7u 4f22ab6477 feat(ui): add hover scale animation to header logo
Add smooth scale-up animation effect when hovering over the header logo to enhance user interaction experience.

Changes:
- Add `group` class to Link element to enable Tailwind group hover functionality
- Update transition from `transition-opacity` to `transition-all` for smooth scaling
- Increase hover scale from `scale-105` to `scale-110` (10% enlargement)
- Maintain 200ms transition duration for optimal user experience

The logo now smoothly scales to 110% size on hover and returns to original size when mouse leaves, providing better visual feedback for user interactions.
2025-08-18 03:43:34 +08:00
t0ng7u cfa7399612 🤔style(ui): remove large size in auth components 2025-08-18 03:39:17 +08:00
t0ng7u 24fbd0bfb0 🎛️ refactor: HeaderBar into modular components, add shared skeletons, and primary-colored nav hover
Summary
- Split HeaderBar into maintainable components and hooks
- Centralized skeleton loading UI via a reusable SkeletonWrapper
- Improved navigation UX with primary-colored hover indication
- Preserved API surface and passed linters

Why
- Improve readability, reusability, and testability of the header
- Remove duplicated skeleton logic across files
- Provide clearer hover feedback consistent with the theme

What’s changed
- Components (web/src/components/layout/HeaderBar/)
  - New container: index.js
  - New UI components: HeaderLogo.js, Navigation.js, ActionButtons.js, UserArea.js, MobileMenuButton.js, NewYearButton.js, NotificationButton.js, ThemeToggle.js, LanguageSelector.js
  - New shared skeleton: SkeletonWrapper.js
  - Updated entry: HeaderBar.js now re-exports ./HeaderBar/index.js
- Hooks (web/src/hooks/common/)
  - New: useHeaderBar.js (state and actions for header)
  - New: useNotifications.js (announcements state, unread calc, open/close)
  - New: useNavigation.js (main nav link config)
- Skeleton refactor
  - Navigation.js: replaced inline skeletons with <SkeletonWrapper type="navigation" .../>
  - UserArea.js: replaced inline skeletons with <SkeletonWrapper type="userArea" .../>
  - HeaderLogo.js: replaced image/title skeletons with <SkeletonWrapper type="image"/>, <SkeletonWrapper type="title"/>
- Navigation hover UX
  - Added primary-colored hover to nav items for clearer pointer feedback
  - Final hover style: hover:text-semi-color-primary (kept rounded + transition classes)

Non-functional
- No breaking API changes; HeaderBar usage stays the same
- All modified files pass lint checks

Notes for future work
- SkeletonWrapper is extensible: add new cases (e.g., card) in one place
- Components are small and test-friendly; unit tests can be added per component

Affected files (key)
- web/src/components/layout/HeaderBar.js
- web/src/components/layout/HeaderBar/index.js
- web/src/components/layout/HeaderBar/Navigation.js
- web/src/components/layout/HeaderBar/UserArea.js
- web/src/components/layout/HeaderBar/HeaderLogo.js
- web/src/components/layout/HeaderBar/ActionButtons.js
- web/src/components/layout/HeaderBar/MobileMenuButton.js
- web/src/components/layout/HeaderBar/NewYearButton.js
- web/src/components/layout/HeaderBar/NotificationButton.js
- web/src/components/layout/HeaderBar/ThemeToggle.js
- web/src/components/layout/HeaderBar/LanguageSelector.js
- web/src/components/layout/HeaderBar/SkeletonWrapper.js
- web/src/hooks/common/useHeaderBar.js
- web/src/hooks/common/useNotifications.js
- web/src/hooks/common/useNavigation.js
2025-08-18 03:20:56 +08:00
t0ng7u 891100dc70 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-18 02:45:04 +08:00
t0ng7u 4da4371003 🎨 refactor: UserInfoHeader layout and styling
- Restructure avatar-name-tags layout to left-right alignment
  - Avatar positioned on the left
  - Name aligned to avatar top, tags aligned to avatar bottom
  - Remove margin-top usage in favor of flexbox justify-between
- Simplify desktop statistics cards to single-line layout
  - Format as "icon + label: value" without stacked layout
  - Remove custom color classes for cleaner styling
- Update UI component styling
  - Increase tag size from small to large
  - Reduce cover height from responsive to fixed 32
  - Add Badge import for future enhancements
  - Clean up icon and text color classes
- Maintain responsive behavior and accessibility
2025-08-18 02:44:53 +08:00
CaIon 61601d38c4 feat: 完善gemini格式转换 2025-08-17 19:08:06 +08:00
CaIon f181ca254b fix: remove redundant reasoning assignment in ChatCompletionsStreamResponseChoiceDelta 2025-08-17 18:43:31 +08:00
t0ng7u 13fcb8fd5d ♻️ refactor: HeaderBar into modular, maintainable components & polish responsive UI
Summary
• Extracted `LogoSection`, `NavLinks`, `UserArea`, and `ActionButtons` from `HeaderBar.js`, reducing complexity and improving readability.
• Removed unused state, handlers, and redundant imports from `HeaderBar.js`.
• Simplified mobile/desktop logic:
  – Menu icon now shows only on mobile `/console` routes.
  – Logo, system name, and mode tags appear on all desktop screens and on mobile non-console pages.
• Reworked skeleton loaders:
  – Narrower width on mobile (`40 px`) and clearer spacing (`p-1`).
• Added global `.scrollbar-hide` utility in `index.css` to enable scrollable areas without visible scrollbars.
• Ensured nav bar is horizontally scrollable across all breakpoints.
• Cleaned up language-switch, New Year, and notice handlers; consolidated side effects.
• Updated imports and internal calls after component extraction.
• Passed required props to new sub-components and removed obsolete ones.
• Confirmed zero linter warnings after refactor.

Why
Breaking the monolithic header into focused components makes future updates simpler, facilitates isolation testing, and aligns with the existing component architecture under `components/`. The UI tweaks provide a better mobile experience and consistent styling across devices.

Notes
No backend changes required. All routes and contexts remain untouched.
2025-08-17 17:50:01 +08:00
t0ng7u 519c26d5f6 feat: unify skeleton loading behavior for Midjourney logs actions
• Introduced `useMinimumLoadingTime` to `MjLogsActions.jsx`, guaranteeing a minimum skeleton display duration for smoother UX
• Refactored loading state UI to use wrapped `Skeleton` with placeholder, matching the implementation in `UsageLogsActions.jsx`
• Kept existing banner, admin checks, and compact-mode toggle intact while streamlining the code
• Ensures consistent loading indicators across usage- and MJ-log tables
2025-08-17 16:53:48 +08:00
t0ng7u 041dd9b4cc Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-17 16:45:30 +08:00
t0ng7u ed3b11a304 🎨 refactor: Setup Wizard UI & Clean Up Redundant Code
Summary of changes
1. SetupWizard.jsx
   • Center card (`min-h-screen flex items-center justify-center`) and remove top margin.
   • Merge step indicator/content into single card; added `Divider` separator.
   • Added sweep-shine animation to current step title via existing `shine-text` class.
   • Simplified imports (removed Avatar / Typography) and deleted unused modal state.

2. Step components
   • Stripped outer `Card` and header sections from `DatabaseStep.jsx`, `AdminStep.jsx`, `UsageModeStep.jsx`, `CompleteStep.jsx` to fit single-card layout.
   • Removed unused imports and props.

3. Components cleanup
   • Deleted obsolete files:
     - `components/setup/components/SetupSteps.jsx`
     - `components/setup/components/modals/UsageModeInfoModal.jsx`
   • Updated `setup/index.jsx` exports accordingly.

4. Styling
   • Ensured global sweep-shine effect already present in `index.css` is reused for step titles.

5. i18n
   • Pruned unused translation keys related to removed components from `i18n/locales/en.json`.

6. Miscellaneous
   • Removed redundant Avatar/Icon imports from multiple files.
   • All linter checks pass with no new warnings.

This commit consolidates the initialization flow into a cleaner, centered single-card wizard, adds visual polish, and reduces dead code for easier maintenance.
2025-08-17 16:45:11 +08:00
CaIon 3e61e6eb52 refactor: extract FlushWriter function for improved stream flushing 2025-08-17 15:30:31 +08:00
CaIon aad6314c51 fix: improve error handling for image edit form request parsing 2025-08-17 15:18:57 +08:00
CaIon bc6c3042b8 fix: remove unnecessary option from error handling in image request conversion 2025-08-17 15:13:17 +08:00
t0ng7u 7411c24954 style(Account UX): resilient binding layout, copyable popovers, pastel header, and custom pay colors
- AccountManagement.js
  - Prevent action button from shifting when account IDs are long by adding gap, min-w-0, and flex-shrink-0; keep buttons in a fixed position.
  - Add copyable Popover for account identifiers (email/GitHub/OIDC/Telegram/LinuxDO) using Typography.Paragraph with copyable; reveal full text on hover.
  - Ensure ellipsis works by rendering the popover trigger as `block max-w-full truncate`.
  - Import Popover and wire up `renderAccountInfo` across all binding rows.

- UserInfoHeader.js
  - Apply unified `with-pastel-balls` background to match PricingVendorIntro.
  - Remove legacy absolute-positioned circles and top gradient bar to avoid visual overlap.

- RechargeCard.jsx
  - Colorize non-Alipay/WeChat/Stripe payment icons using backend `pay_methods[].color`; fallback to `var(--semi-color-text-2)`.
  - Add `showClear` to the redemption code input for quicker clearing.

Notes:
- No linter errors introduced.
- i18n strings and behavior remain unchanged except for improved UX and visual consistency.
2025-08-17 11:45:55 +08:00
t0ng7u bbe381f656 Revert "️ perf: Defer Visactor chart libs to dashboard; minimize home bundle"
This reverts commit 45368deac3.
2025-08-17 10:49:09 +08:00
t0ng7u b505121790 💳 feat(TopUp): unify payment cards, add header stats, brand icons, and mobile refinements [[memory:5659506]]
- Add RightStatsCard and place it in RechargeCard header
  - Shows current balance, historical spend, and request count
  - Mobile: stacks under title; three metrics split equally (flex-1); vertical dividers hidden on small screens
  - Remove extra margins; small card styling

- RechargeCard
  - Replace redeem code Input icon with Semi UI IconGift
  - Style “Payable amount” number in red and bold; keep same style in confirm modal
  - Always render payment methods as Cards (remove Button variant) with adaptive grid
  - Use brand color icons: SiAlipay (#1677FF), SiWechat (#07C160), SiStripe (#635BFF)
  - Replace Stripe icon with SiStripe
  - Integrate RightStatsCard props; adjust header to flex-col on mobile and flex-row on desktop
  - Hide Banner close button when online top-up is disabled (closeIcon={null})

- InvitationCard
  - Simplify to match RechargeCard’s minimalist slate style
  - Use Card title for “Rewards” and place content directly in body
  - Improve link input and copy button; use Badge dots for bullet points

- TopUp index
  - Remove separate right-column stats card; pass userState and renderQuota to RechargeCard

- Cleanup
  - Lint passes; no functional changes to APIs or business logic
2025-08-17 04:00:58 +08:00
t0ng7u 45368deac3 ️ perf: Defer Visactor chart libs to dashboard; minimize home bundle
Home started loading `/assets/visactor-*.js` due to static imports of `@visactor/react-vchart` and the Semi theme in dashboard components/hooks. This change moves chart dependencies to lazy/dynamic imports so they load only on dashboard routes.

Changes
- StatsCards.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- ChartsPanel.jsx: replace static `VChart` import with `React.lazy` + `Suspense` (fallback: null)
- useDashboardCharts.js: remove static `initVChartSemiTheme` import; dynamically import and initialize the theme inside `useEffect` with a cancel guard

Behavior
- Home page no longer downloads `visactor` chunks on first load
- Chart libraries are fetched only when visiting `/console` (dashboard)
- No functional changes to chart rendering

Files
- web/src/components/dashboard/StatsCards.jsx
- web/src/components/dashboard/ChartsPanel.jsx
- web/src/hooks/dashboard/useDashboardCharts.js

Verification
- Build the app (`npm run build`) and open `/`: no `/assets/visactor-*.js` requests
- Navigate to `/console`: `visactor` chunks load and charts render as expected

Breaking Changes
- None

Follow-ups
- If needed, further trim homepage bundle by reducing heavy icon sets on the hero section
2025-08-17 01:22:44 +08:00
t0ng7u 66289f7991 ♻️ refactor(personal-settings): Break down PersonalSetting.js into modular components
- Split the 1554-line PersonalSetting.js file into smaller, maintainable components
- Created organized folder structure under personal/:
  - components/: UserInfoHeader for shared user info display
  - tabs/: ModelsList, AccountBinding, SecuritySettings, NotificationSettings
  - modals/: EmailBindModal, WeChatBindModal, AccountDeleteModal, ChangePasswordModal
- Refactored main PersonalSetting component to use composition pattern
- Improved code maintainability and separation of concerns
- Added collapsible prop to ModelsList tabs for better UX
- Fixed import path for TwoFASetting component in SecuritySettings
- Preserved all existing functionality and user interactions

This refactoring reduces the main file from 1554 to 484 lines and makes
the codebase more modular, testable, and easier to maintain.
2025-08-17 00:49:54 +08:00
funnycups 2f176cff7f fix: prompt calculation
User will correctly get estimated prompt usage when upstream returns either zero or nothing.
2025-08-16 22:54:00 +08:00
t0ng7u 6ce92b9e19 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 19:22:24 +08:00
t0ng7u 3004ede0ce 🌐 feat(i18n): add internationalization support for TwoFASetting component
- Add comprehensive i18n support to TwoFASetting.js component
- Add all required English translations to en.json for 2FA settings
- Update component to accept t function as prop and use translation keys
- Fix prop passing in PersonalSetting.js to provide t function
- Maintain all existing UI improvements and functionality
- Support both Chinese and English interfaces for:
  * Main 2FA settings card with status indicators
  * Setup modal with guided steps (QR scan, backup codes, verification)
  * Disable 2FA modal with impact warnings and confirmation
  * Regenerate backup codes modal with success states
  * All buttons, placeholders, messages, and notifications
- Follow project i18n conventions using t('key') pattern
- Ensure seamless language switching for enhanced user experience

This enables the 2FA settings to be fully localized while preserving
the modern UI design and improved user workflow from previous updates.
2025-08-16 19:22:14 +08:00
CaIon 4e3f008ae9 fix: update token usage calculation 2025-08-16 19:11:15 +08:00
t0ng7u a6c5b6d09f Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 18:57:55 +08:00
t0ng7u d094df09a2 💄 refactor(settings): redesign 2FA settings UI with unified Semi UI components
- Replace lucide-react icons with Semi UI icons for consistency
- Implement Steps component for guided 2FA setup modal flow
- Redesign disable and regenerate backup codes modals to match setup modal style
- Extract duplicate backup codes display logic into reusable BackupCodesDisplay component
- Move modal navigation buttons to proper footer parameter following Semi UI standards
- Replace custom styled dots with Badge components (warning/danger/success types)
- Use Banner and Divider components for better visual hierarchy
- Remove redundant modal step titles and download functionality
- Apply consistent rounded corners, spacing, and color scheme across all modals
- Improve responsive design with maxWidth constraints

This unifies the 2FA settings visual design with other settings pages and
enhances user experience through better component usage and layout structure.
2025-08-16 18:57:46 +08:00
Calcium-Ion 14ea270406 Merge pull request #1605 from nekohy/feats-the-flexable-params-override
Feats: use the types of gjson,the error expection,the invert and the key missing process  of the condition
2025-08-16 18:27:22 +08:00
CaIon c06d9485c3 feat: add support for Midjourney relay mode based on path prefix 2025-08-16 18:26:26 +08:00
Nekohy 9e649148bb feats:the error of gjson.True and gjson.False 2025-08-16 17:16:46 +08:00
Nekohy 7323dcf906 feats:custom the key missing condition 2025-08-16 16:37:09 +08:00
Nekohy 5683f4b95f feats:use the types of gjson,the error expection,the invert of the condition 2025-08-16 16:31:17 +08:00
CaIon 3c30b7c4cb fix: refactor processChannelError to use goroutine for asynchronous handling 2025-08-16 15:15:19 +08:00
CaIon 22125a6dd8 fix: correct argument passing in DoDownloadRequest for MIME type retrieval 2025-08-16 15:03:42 +08:00
CaIon 3a90d5054c feat: implement file type detection from URL with enhanced MIME type handling 2025-08-16 14:56:29 +08:00
CaIon 11ce48be47 feat: set API version for Azure and Vertex AI channel types 2025-08-16 14:56:19 +08:00
Calcium-Ion 1d878e5e88 Merge pull request #1603 from nekohy/feats-the-flexable-params-override
feats: the flexable params override and compatible format
2025-08-16 12:32:15 +08:00
CaIon 28d381982d Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-16 12:28:46 +08:00
Calcium-Ion a5420c90c0 Merge pull request #1584 from feitianbubu/pr/use-proxy-fetch-models
feat: use proxy HTTP client fetch models
2025-08-16 12:27:36 +08:00
Calcium-Ion 5fbbb24201 Merge pull request #1597 from wzxjohn/feature/add_openai_models
feat(relay): add OpenAI gpt-4.1 o3 o4 gpt-image-1 models
2025-08-16 12:27:20 +08:00
Nekohy 02401b9a38 fix:moveValue bug 2025-08-16 11:58:40 +08:00
Nekohy 4b6031b59c feats: the flexable params override and compatible format 2025-08-16 11:27:47 +08:00
CaIon cf9faeb901 feat: implement concurrent top-up locking mechanism to prevent race conditions 2025-08-15 20:03:38 +08:00
CaIon f8f0ee1e3e feat: initialize channel metadata in mjproxy and relay processing 2025-08-15 19:14:29 +08:00
CaIon f6ee0b9791 feat: add checks for non-empty URLs in file metadata processing 2025-08-15 19:10:40 +08:00
CaIon 0e95efdf34 feat: initialize channel metadata in relay processing 2025-08-15 19:00:16 +08:00
CaIon beb61343cd refactor: comment out SetContextKey to prevent token count meta setting 2025-08-15 18:43:08 +08:00
CaIon 77b100ba2b refactor: update function signatures to include context and improve file handling #1599 2025-08-15 18:40:54 +08:00
wzxjohn 28dd5f5f0c feat(ratio): add ratio for OpenAI models 2025-08-15 17:12:39 +08:00
wzxjohn 269bb0c896 feat(relay): add OpenAI gpt-4.1 o3 o4 gpt-image-1 models 2025-08-15 17:10:16 +08:00
CaIon 215546a805 feat: 优化ac自动机 2025-08-15 16:54:26 +08:00
CaIon 63e7fb697f Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-15 16:47:36 +08:00
Calcium-Ion 30e4679384 Merge pull request #1577 from nekohy/feats-better-adaptor-for-openrouter
Fix reasoning adaptor for openrouter
2025-08-15 16:19:24 +08:00
CaIon 893104a173 refactor: move anthropicKey retrieval to improve authorization handling 2025-08-15 16:08:55 +08:00
CaIon 8284cff9b7 refactor: set prompt tokens when not provided in usage 2025-08-15 15:55:01 +08:00
CaIon e1d36932ae fix panic 2025-08-15 15:15:21 +08:00
CaIon 5084a43aa9 refactor: ensure graceful closure of response body in relay responses 2025-08-15 15:10:54 +08:00
Calcium-Ion c6c40e0a81 Merge pull request #1590 from yyhhyyyyyy/fix/openrouter-custom-ratio-billing
fix: prevent OpenRouter cache calculation with custom model ratios
2025-08-15 15:02:57 +08:00
yyhhyyyyyy ccd1930838 Merge remote-tracking branch 'origin/alpha' into fix/openrouter-custom-ratio-billing 2025-08-15 14:58:42 +08:00
CaIon d849412cf5 refactor: replace json.Marshal and json.Unmarshal with common.Marshal and common.Unmarshal 2025-08-15 14:52:17 +08:00
同語 712865f820 Merge pull request #1593 from fatcat-ww/main
手机客户端下拉菜单采用导航栏的样式
2025-08-15 14:30:18 +08:00
Calcium-Ion d3a2bf79f5 Merge pull request #1594 from QuantumNous/refactor_relay
refactor: Introduce pre-consume quota and unify relay handlers
2025-08-15 14:16:43 +08:00
CaIon 07903b0a8a refactor: improve logging for channel operations with detailed context 2025-08-15 14:15:03 +08:00
CaIon 9da13fdabe refactor: enhance logging messages for user quota handling in pre-consume logic 2025-08-15 14:08:15 +08:00
CaIon 4858e98b2d refactor: simplify domain masking logic by removing URL check 2025-08-15 13:46:46 +08:00
CaIon e0f256506f Merge branch 'alpha' into refactor_relay
# Conflicts:
#	dto/openai_image.go
2025-08-15 13:46:34 +08:00
CaIon 07137e6bb8 refactor: update token type handling and improve token counting logic 2025-08-15 13:28:03 +08:00
CaIon 8eb17f24bb refactor: improve request type validation and enhance sensitive information masking 2025-08-15 13:20:36 +08:00
CaIon dba74e5b05 refactor: add email masking function and enhance RelayInfo logging
This commit introduces a new function, MaskEmail, to mask user email addresses in logs, preventing PII leakage. Additionally, the RelayInfo logging has been updated to utilize this new masking function, ensuring sensitive information is properly handled. The channel test logic has also been improved to dynamically determine the relay format based on the request path.
2025-08-15 12:50:27 +08:00
CaIon 013b30c228 refactor: enhance error handling and masking for model not found scenarios 2025-08-15 12:41:05 +08:00
CaIon f6f9e73fa3 refactor: improve channel base URL handling and enhance RelayInfo logging 2025-08-14 22:15:18 +08:00
CaIon 112de0b6a6 refactor: enhance quota handling and logging for pre-consume operations 2025-08-14 21:30:03 +08:00
CaIon c9e9a24e99 refactor: centralize logging and update resource initialization
This commit refactors the logging mechanism across the application by replacing direct logger calls with a centralized logging approach using the `common` package. Key changes include:

- Replaced instances of `logger.SysLog` and `logger.FatalLog` with `common.SysLog` and `common.FatalLog` for consistent logging practices.
- Updated resource initialization error handling to utilize the new logging structure, enhancing maintainability and readability.
- Minor adjustments to improve code clarity and organization throughout various modules.

This change aims to streamline logging and improve the overall architecture of the codebase.
2025-08-14 21:10:04 +08:00
fatcat-ww 39a9e828a1 Add files via upload 2025-08-14 20:38:50 +08:00
CaIon 97ea8b6560 refactor: Introduce pre-consume quota and unify relay handlers
This commit introduces a major architectural refactoring to improve quota management, centralize logging, and streamline the relay handling logic.

Key changes:
- **Pre-consume Quota:** Implements a new mechanism to check and reserve user quota *before* making the request to the upstream provider. This ensures more accurate quota deduction and prevents users from exceeding their limits due to concurrent requests.

- **Unified Relay Handlers:** Refactors the relay logic to use generic handlers (e.g., `ChatHandler`, `ImageHandler`) instead of provider-specific implementations. This significantly reduces code duplication and simplifies adding new channels.

- **Centralized Logger:** A new dedicated `logger` package is introduced, and all system logging calls are migrated to use it, moving this responsibility out of the `common` package.

- **Code Reorganization:** DTOs are generalized (e.g., `dalle.go` -> `openai_image.go`) and utility code is moved to more appropriate packages (e.g., `common/http.go` -> `service/http.go`) for better code structure.
2025-08-14 20:05:06 +08:00
yyhhyyyyyy 7d2a9469f0 fix: prevent OpenRouter cache calculation with custommodel ratios 2025-08-14 17:10:36 +08:00
t0ng7u f1afe914e6 🐛 fix(tokens): correct main Chat button navigation to prevent 404
The primary "Chat" button on the tokens table navigated to a 404 page
because it passed incorrect arguments to onOpenLink (using a raw
localStorage value instead of the parsed chat value).

Changes:
- Build chatsArray with an explicit `value` for each item.
- Use the first item's `name` and `value` for the main button, matching
  the dropdown behavior.
- Preserve existing error handling when no chats are configured.

Impact:
- Main "Chat" button now opens the correct link, consistent with the
  dropdown action.
- No API/schema changes, no UI changes.

File:
- web/src/components/table/tokens/TokensColumnDefs.js

Verification:
- Manually verified primary button and dropdown both navigate correctly.
- Linter passes with no issues.
2025-08-13 18:31:00 +08:00
feitianbubu c57f33bff6 feat: use proxy HTTP client fetch models 2025-08-13 18:17:11 +08:00
wzxjohn c2e49f2b2a fix(adaptor): missing first text delta while convert OpenAI to Claude 2025-08-13 09:57:06 +08:00
t0ng7u b699bf0094 🐛 fix: Always update searchValue during IME composition to enable Chinese input in model search
Summary:
• Removed early return in `handleChange` that blocked controlled value updates while an Input Method Editor (IME) was composing text.
• Ensures that Chinese (and other IME-based) characters appear immediately in the “Fuzzy Search Model Name” field.
• No change to downstream filtering logic—`searchValue` continues to drive model list filtering as before.

Files affected:
web/src/hooks/model-pricing/useModelPricingData.js
2025-08-12 23:37:30 +08:00
t0ng7u b6c353ec14 🔍 fix(pricing): synchronize search term with sidebar filters & reset behavior
* Add `searchValue` to every dependency array in `usePricingFilterCounts`
  to ensure group/vendor/tag counts and disabled states update dynamically
  while performing fuzzy search.

* Refactor `PricingTopSection` search box into a controlled component:
  - Accept `searchValue` prop and bind it to `Input.value`
  - Extend memo dependencies to include `searchValue`
  This keeps the UI in sync with state changes triggered by `handleChange`.

* Guarantee that `resetPricingFilters` clears the search field by
  leveraging the new controlled input.

As a result, sidebar counters/disabled states now react to search input,
and the “Reset” button fully restores default filters without leaving the
search term visible.
2025-08-12 23:32:25 +08:00
t0ng7u 611829bb9e Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 23:11:55 +08:00
t0ng7u e6925309ce 🐛 fix: group-ratio display & deduplicate price logic in model-pricing views
Summary
• Ensure “Group Ratio” shows correctly when “All” groups are selected.
• Eliminate redundant price calculations in both card and table views.

Details
1. PricingCardView.jsx
   • Removed obsolete renderPriceInfo function.
   • Calculate priceData once per model and reuse for header price string and footer ratio block.
   • Display priceData.usedGroupRatio as the group ratio fallback.

2. PricingTableColumns.js
   • Introduced WeakMap-based cache (getPriceData) to compute priceData only once per row.
   • Updated ratioColumn & priceColumn to reuse cached priceData.
   • Now displays priceData.usedGroupRatio, preventing empty cells for “All” group.

Benefits
• Correct visual output for group ratio across all views.
• Reduced duplicate calculations, improving render performance.
• Removed dead code, keeping components clean and maintainable.
2025-08-12 23:10:29 +08:00
Nekohy ebb624148b feats:the Openrouter Claude thinking 2025-08-12 22:39:32 +08:00
Nekohy 4a49a80d10 fix:Delete the excess ReasoningEffort from the Openrouter OpenAI thinking model. 2025-08-12 21:37:12 +08:00
CaIon f21353bfbb fix(dalle): update ImageRequest struct to use json.RawMessage for flexible field types 2025-08-12 21:12:00 +08:00
CaIon b850f94139 fix(auth): refine authorization header setting for messages endpoint #1575 2025-08-12 20:42:44 +08:00
CaIon c7281a353f fix(env): update STREAMING_TIMEOUT default value to 300 seconds 2025-08-12 19:58:04 +08:00
CaIon 1382ec4f20 fix(adaptor): optimize multipart form handling and resource management 2025-08-12 19:57:56 +08:00
IcedTangerine 9717af7dcd Merge pull request #1553 from feitianbubu/pr/add-jimeng-officail-api
feat: add jimeng video official api
2025-08-12 16:32:49 +08:00
CaIon 219ed0d1cf fix(database): improve MySQL Chinese character support validation 2025-08-12 16:31:00 +08:00
CaIon 9625ee2d55 fix(relay): remove unnecessary channel type check for BadRequest 2025-08-12 16:12:47 +08:00
Calcium-Ion 23d4100fe3 Merge pull request #1556 from QuantumNous/fix-register-mail-verifycode-waiting-time
fix: 注册时发送邮件验证码没有等待时间
2025-08-12 14:20:09 +08:00
CaIon 5b8382a6ab Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 14:13:34 +08:00
Calcium-Ion 9ec326714e Merge pull request #1569 from duyazhe/codex/cloudflare-responses
feat: add responses support for cloudflare
2025-08-12 14:13:14 +08:00
CaIon a423ee3dd1 feat(database): enhance MySQL support for Chinese characters
- Added a check for MySQL charset/collation to ensure compatibility with Chinese characters during database initialization.
- Updated SQLite busy timeout from 5000ms to 30000ms for improved performance.
- Removed commented-out PostgreSQL migration logic for clarity.
2025-08-12 14:12:11 +08:00
同語 f10d469b72 Update web_api.md 2025-08-12 11:15:32 +08:00
同語 fd6b6175bb Merge pull request #1563 from QAbot-zh/docs-update
docs(web_api): 修复 Markdown 表格格式
2025-08-12 11:14:35 +08:00
t0ng7u c59b331170 🚫 feat(web): add 403 Forbidden page and AdminRoute guard
- Add new Forbidden page at /forbidden (`web/src/pages/Forbidden/index.js`)
  - Use Semi-UI Empty with IllustrationNoAccess (250x250)
  - Update i18n description to: '您无权访问此页面,请联系管理员~'
  - Align visual style with existing 404 page
- Introduce `AdminRoute` in `web/src/helpers/auth.js`
  - Use `UserContext`/localStorage; redirect to `/forbidden` when `!user` or `user.role < 10`
- Protect console/admin routes with `AdminRoute` and register `/forbidden` in `web/src/App.js`
- Update `web/src/i18n/locales/en.json`
  - Add English translation for the new forbidden message
  - Remove legacy "没有权限" entry
- Lint passes; no runtime errors observed
2025-08-12 10:45:21 +08:00
t0ng7u 729593afae Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 10:22:13 +08:00
t0ng7u 782ad01496 🐛 fix: make ModelSelectModal panels collapsible and default to collapsed
- Switch Collapse from controlled (activeKey) to uncontrolled (defaultActiveKey) so user toggling works
- Add a stable key to reset Collapse state when tab/category changes
- Default all panels to collapsed via defaultActiveKey: []
- Preserve Panel itemKey for consistent behavior
- No linter errors introduced

Scope: web/src/components/table/channels/modals/ModelSelectModal.jsx
2025-08-12 10:22:00 +08:00
Seefs 4e2e6e6e86 Merge pull request #1570 from seefs001/fix/zhipu_v4_thinking
fix: zhipu_v4 thinking
2025-08-11 21:43:35 +08:00
nekohy 5478d2fb59 fix: zhipu_v4 thinking 2025-08-11 21:37:10 +08:00
CaIon 32978f9cd9 feat: Simplify response handling by returning raw response body directly 2025-08-11 20:07:24 +08:00
CaIon ce74e94fc7 feat: Refactor Gemini tools handling to support JSON raw message format 2025-08-11 19:48:04 +08:00
t0ng7u 7d3bf7c5bb 🍭 ui: change pricing page card view pt-4 to py-4 2025-08-11 19:11:58 +08:00
t0ng7u 472377bf8c 🍭 ui: change pricing page card view p-4 to px-4 2025-08-11 18:32:19 +08:00
t0ng7u 6c7a10ac7c ui: Add CSS ellipsis + Tooltip for SelectableButtonGroup; keep Tag intact
- Truncate long labels via pure CSS and always show full text in a Tooltip
- Ensure the right-side Tag is never truncated and remains fully visible
- Simplify implementation: remove overflow detection and ResizeObserver
- Use minimal markup with sbg-button/sbg-inner/sbg-label to enable shrinking
- Add global rules to allow `.semi-button-content` to shrink and ellipsize

Files:
- web/src/components/common/ui/SelectableButtonGroup.jsx
- web/src/index.css

No API changes; visuals improved and code complexity reduced.
2025-08-11 18:27:32 +08:00
t0ng7u 375e25221d 🤓 style(ui): remove the pricing page’s border style to reduce visual clutter 2025-08-11 17:44:12 +08:00
t0ng7u 2b6e3e8010 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-11 17:38:16 +08:00
CaIon a80ecb8896 feat: Refactor model handling to use UpstreamModelName for request processing 2025-08-11 17:32:58 +08:00
CaIon 37f0383941 feat: Update request URL handling for Claude relay format in adaptor #1557 2025-08-11 17:17:56 +08:00
feitianbubu 5520bf4dbe feat: init default vendor 2025-08-11 16:47:12 +08:00
CaIon c9b276c604 Merge remote-tracking branch 'origin/alpha' into alpha
# Conflicts:
#	relay/channel/openai/adaptor.go
2025-08-11 16:35:23 +08:00
Q.A.zh 6193c547d9 docs(web_api): 修复 Markdown 表格格式 2025-08-11 08:34:14 +00:00
CaIon be77f3d763 feat: Update Azure responses API version handling in adaptor 2025-08-11 16:34:07 +08:00
t0ng7u 337bb41588 💄 style: Use segmented renderer for billing types in Models table; keep Pricing view unchanged
Frontend
- Models table (model management):
  - Render billing types with the same segmented list component (renderLimitedItems) used by tags and endpoints
  - Display quota_types as an array with capped items (maxDisplay: 3) and graceful fallback for unknown types
- Pricing view (unchanged by request):
  - Revert to single-value quota_type rendering and sorter
  - Keep ratio display logic based on quota_type only

Files
- web/src/components/table/models/ModelsColumnDefs.js
- web/src/components/table/model-pricing/view/table/PricingTableColumns.js

Notes
- This commit only adjusts the model management UI rendering; pricing views remain as-is
2025-08-11 15:53:55 +08:00
duyazhe 8df5a45805 feat: add responses support for cloudflare 2025-08-11 15:29:16 +08:00
t0ng7u ec006d3a67 🚀 perf: optimize model management APIs, unify pricing types as array, and remove redundancies
Backend
- Add GetBoundChannelsByModelsMap to batch-fetch bound channels via a single JOIN (Distinct), compatible with SQLite/MySQL/PostgreSQL
- Replace per-record enrichment with a single-pass enrichModels to avoid N+1 queries; compute unions for prefix/suffix/contains matches in memory
- Change Model.QuotaType to QuotaTypes []int and expose quota_types in responses
- Add GetModelQuotaTypes for cached O(1) lookups; exact models return a single-element array
- Sort quota_types for stable output order
- Remove unused code: GetModelByName, GetBoundChannels, GetBoundChannelsForModels, FindModelByNameWithRule, buildPrefixes, buildSuffixes
- Clean up redundant comments, keeping concise and readable code

Frontend
- Models table: switch to quota_types, render multiple billing modes ([0], [1], [0,1], future values supported)
- Pricing table: switch to quota_types; ratio display now checks quota_types.includes(0); array rendering for billing tags

Compatibility
- SQL uses standard JOIN/IN/Distinct; works across SQLite/MySQL/PostgreSQL
- Lint passes; no DB schema changes (quota_types is a JSON response field only)

Breaking Change
- API field renamed: quota_type -> quota_types (array). Update clients accordingly.
2025-08-11 14:40:01 +08:00
t0ng7u 067f3ce9ed 🔧 chore(db): drop legacy single-column UNIQUE indexes to prevent duplicate-key errors after soft-delete
Why
Previous versions created single-column UNIQUE constraints (`models.model_name`, `vendors.name`).
After introducing composite indexes on `(model_name, deleted_at)` and `(name, deleted_at)` for soft-delete support, those legacy constraints could still exist in user databases.
When a record was soft-deleted and re-inserted with the same name, MySQL raised `Error 1062 … for key 'models.model_name'`.

What
• In `migrateDB` and `migrateDBFast` paths of `model/main.go`, proactively drop:
  – `models.uk_model_name` and fallback `models.model_name`
  – `vendors.uk_vendor_name` and fallback `vendors.name`
• Keeps existing helper `dropIndexIfExists` to ensure the operation is MySQL-only and error-free when indexes are already absent.

Result
Startup migration now removes every possible legacy UNIQUE index, ensuring composite index strategy works correctly.
Users can soft-delete and recreate models/vendors with identical names without hitting duplicate-entry errors.
2025-08-11 01:25:13 +08:00
t0ng7u 649fa84231 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 23:18:16 +08:00
creamlike1024 46cd3f4071 feat(middleware): redis atomic incr, show waiting time 2025-08-10 23:18:09 +08:00
t0ng7u 8d54f86261 🤓 chore: format code file 2025-08-10 23:17:04 +08:00
t0ng7u 0bcd7388f4 🏎️ perf: optimize aggregated model look-ups by batching bound-channel queries
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `GetBoundChannelsForModels([]string)` to retrieve channels for multiple models in a single SQL (`IN (?)`) and deduplicate with `GROUP BY`.

   • `controller/model_meta.go`
     – In non-exact `fillModelExtra`:
       – Remove per-model `GetBoundChannels` calls.
       – Collect matched model names, then call `GetBoundChannelsForModels` once and merge results into `channelSet`.
       – Minor cleanup on loop logic; channel aggregation now happens after quota/group/endpoint processing.

Impact
------
• Eliminates N+1 query pattern for prefix/suffix/contains rules.
• Reduces DB round-trips from *N + 1* to **1**, markedly speeding up the model-management list load.
• Keeps existing `GetBoundChannels` API intact for single-model scenarios; no breaking changes.
2025-08-10 23:11:35 +08:00
Seefs 3b6d0d0291 Merge pull request #1547 from seefs001/feature/model_list
 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling
2025-08-10 22:57:20 +08:00
t0ng7u 51f7f89190 🔍 feat: Show matched model names & counts for non-exact model rules
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `MatchedModels []string` and `MatchedCount int` (ignored by GORM) to expose matching details in API responses.
   • `controller/model_meta.go`
     – When processing prefix/suffix/contains rules in `fillModelExtra`, collect every matched model name, fill `MatchedModels`, and calculate `MatchedCount`.

2. **Frontend**
   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Import `Tooltip`.
     – Enhance `renderNameRule` to:
       – Display tag text like “前缀 5个模型” for non-exact rules.
       – Show a tooltip listing all matched model names on hover.

Impact
------
Users now see the total number of concrete models aggregated under each prefix/suffix/contains rule and can inspect the exact list via tooltip, improving transparency in model management.
2025-08-10 21:32:18 +08:00
creamlike1024 ff2e0dbc26 feat(middleware): add email verification rate limit 2025-08-10 21:22:53 +08:00
t0ng7u e1d51fd169 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 21:14:47 +08:00
t0ng7u 85ecfd7062 feat: enhance model billing aggregation & UI display for unknown quota type
Summary
-------
1. **Backend**
   • `controller/model_meta.go`
     – For prefix/suffix/contains rules, aggregate endpoints, bound channels, enable groups, and quota types across all matched models.
     – When mixed billing types are detected, return `quota_type = -1` (unknown) instead of defaulting to volume-based.

2. **Frontend**
   • `web/src/helpers/utils.js`
     – `calculateModelPrice` now handles `quota_type = -1`, returning placeholder `'-'`.

   • `web/src/components/table/model-pricing/view/card/PricingCardView.jsx`
     – Billing tag logic updated: displays “按次计费” (times), “按量计费” (volume), or `'-'` for unknown.

   • `web/src/components/table/model-pricing/view/table/PricingTableColumns.js`
     – `renderQuotaType` shows “未知” for unknown billing type.

   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Unified `renderQuotaType` to return `'-'` when type is unknown.

   • `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx`
     – Group price table honors unknown billing type; pricing columns show `'-'` and neutral tag color.

3. **Utilities**
   • Added safe fallback colours/tags for unknown billing type across affected components.

Impact
------
• Ensures correct data aggregation for non-exact model matches.
• Prevents UI from implying volume billing when actual type is ambiguous.
• Provides consistent placeholder display (`'-'` or “未知”) across cards, tables and modals.

No breaking API changes; frontend gracefully handles legacy values.
2025-08-10 21:09:49 +08:00
CaIon 7ffbff88f1 feat: Update request URL handling for Azure responses based on base URL 2025-08-10 21:09:16 +08:00
CaIon 8907e5cf6d feat: Add ChannelOtherSettings to manage additional channel configurations 2025-08-10 20:21:30 +08:00
creamlike1024 8217e694f8 fix: 注册时发送邮件验证码没有等待时间 2025-08-10 19:15:26 +08:00
t0ng7u 2cabe4f6ac 🔠 refactor: refine group label formatting in price info
Summary:
• Updated `helpers/utils.js` to display the “group” label without a colon, ensuring consistent typography with other price elements.

Details:
1. `formatPriceInfo`
   – Changed `{t('分组')}:` to `{t('分组')}` for a cleaner look.
   – Keeps spacing intact between label and selected group name.
2. No functional impact; purely visual polish.
2025-08-10 17:17:49 +08:00
feitianbubu 85723e1fe3 feat: if video cannot play open in a new tab 2025-08-10 17:07:29 +08:00
feitianbubu 73609f50d0 feat: add jimeng video official api 2025-08-10 16:54:44 +08:00
t0ng7u ca85d224f1 📱 style: Hide vendor introduction on mobile devices
Summary:
• Updated `PricingTopSection.jsx` to conditionally render `PricingVendorIntroWithSkeleton` only when `isMobile` is false.

Details:
1. Wrapped vendor-intro block in `!isMobile` check, preventing unnecessary content on small screens.
2. Kept desktop experience unchanged; no impact on other features.
3. Lint check passed with no new issues.

Result:
Cleaner mobile UI with improved performance and visual focus.
2025-08-10 14:10:50 +08:00
t0ng7u b6dd701ce8 feat: Add tag-based filtering & refactor filter counts logic
Overview:
• Introduced a new “Model Tag” filter across pricing screens
• Refactored `usePricingFilterCounts` to eliminate duplicated logic
• Improved tag handling to be case-insensitive and deduplicated
• Extended utilities to reset & persist the new filter

Details:
1. Added `filterTag` state to `useModelPricingData` and integrated it into all filtering paths.
2. Created reusable `PricingTags` component using `SelectableButtonGroup`.
3. Incorporated tag filter into `PricingSidebar` and mobile `PricingFilterModal`, including reset support.
4. Enhanced `resetPricingFilters` (helpers/utils) to restore tag filter defaults.
5. Refactored `usePricingFilterCounts.js`:
   • Centralized predicate `matchesFilters` to remove redundancy
   • Normalized tag parsing via `normalizeTags` helper
   • Memoized model subsets with concise filter calls
6. Updated lints – zero errors after refactor.

Result:
Users can now filter models by custom tags with consistent UX, and internal logic is cleaner, faster, and easier to extend.
2025-08-10 14:05:25 +08:00
t0ng7u 7130ec8e32 🐛 fix(PricingCardView): hide placeholder dash when no custom tags
Previously, the card view displayed a “-” whenever a model had no custom tags,
because `renderLimitedItems` returned a dash for an empty array.
Now the function is only invoked when `customTags.length > 0`, removing the
unwanted placeholder and keeping the UI clean.

File affected:
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
2025-08-10 13:45:16 +08:00
t0ng7u c79b6cea32 fix: prevent model name flicker when closing SideSheet
- Delay clearing selectedModel until SideSheet close animation completes
- Prevents brief display of 'AI/Unknown Model' text during closing transition
- Improves user experience by eliminating visual flicker
- Uses 300ms timeout matching Semi UI default animation duration
2025-08-10 13:41:19 +08:00
creamlike1024 f8c2c78ee2 feat: update openai websearch price 2025-08-10 13:37:49 +08:00
CaIon 54f470bc46 feat: Enhance EditChannelModal with JSONEditor key updates and input reset
- Added unique keys for JSONEditor components to ensure proper re-rendering based on channelId.
- Implemented input reset to clear previous JSON field values when the modal is opened.
2025-08-10 12:22:18 +08:00
t0ng7u 1ea54eb2ed 🖼️ chore: format code file 2025-08-10 12:11:31 +08:00
nekohy 4cf1ffa801 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling 2025-08-10 11:44:38 +08:00
t0ng7u 750dea9de7 🐛 fix(model): allow zero-value updates so tags/description can be cleared 2025-08-10 11:03:39 +08:00
Calcium-Ion ac9a1ae876 Merge pull request #1496 from Bliod-Cook/feat-linuxdo-minumum-trust-level
feat: allow admin to restrict the minimum linuxdo trust level to register
2025-08-10 10:27:40 +08:00
Calcium-Ion c59253ea6e Merge pull request #1537 from RedwindA/feat/support-native-gemini-embedding
feat: 支持原生Gemini Embedding格式
2025-08-10 10:26:46 +08:00
Calcium-Ion 571301adfd Merge pull request #1541 from QuantumNous/fluent-read
feat: added "流畅阅读" (FluentRead) as a new chat provider option.
2025-08-10 10:26:22 +08:00
t0ng7u abee65c1db feat: Add model icon support across backend and UI; prefer model icon over vendor; add icon column in Models table
Backend:
- Model: Add `icon` field to `model.Model` (gorm: varchar(128)); auto-migrated via GORM.
- Pricing API: Extend `model.Pricing` with `icon` and populate from model meta in `GetPricing()`.

Frontend:
- EditModelModal: Add `icon` input (with @lobehub/icons helper link); wire into init/load/submit flows.
- ModelHeader / PricingCardView: Prefer rendering `model.icon`; fallback to `vendor_icon`; final fallback to initials avatar.
- Models table: Add leading “Icon” column, rendering `model.icon` or `vendor` icon via `getLobeHubIcon`.

Notes:
- Backward-compatible. Existing data without `icon` remain unaffected.
- No manual SQL needed; column is added by AutoMigrate.

Affected files:
- model/model_meta.go
- model/pricing.go
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/model-pricing/modal/components/ModelHeader.jsx
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
- web/src/components/table/models/ModelsColumnDefs.js
2025-08-10 01:38:59 +08:00
t0ng7u 734ef7ddac feat: Support dot‑chained props for LobeHub icons
- render.js: Enhance getLobeHubIcon to parse dot‑chained props, e.g.:
  - OpenAI.Avatar.type={'platform'}
  - OpenRouter.Avatar.shape={'square'}
  - Parses booleans/numbers/strings and {…} wrappers; keeps the 2nd arg `size` unless overridden by chain props. Backward compatible.
- EditVendorModal.jsx: Update UI copy — simplify placeholder; document chain‑parameter examples in extra text with doc link.
- en.json: Fix invalid escape sequences in the new i18n string to satisfy linter.

No behavioral changes outside icon rendering; lints pass.
2025-08-10 01:18:36 +08:00
t0ng7u 58af8ed688 🍎 chore: modify pagination pageSizeOpts 2025-08-10 00:58:17 +08:00
t0ng7u 8a3107448b 🎨 refactor: MultiKeyManageModal: cleaner stats UI, remove chart, integrate toolbar/pagination, and improve UX
- Replace custom dots with Semi Badge types (success/danger/warning); add compact Progress bars
- Remove pie chart and related deps/config; move total key count and mode tags into the modal title
- Rework header using Row/Col; three equal stat cards (enabled/manual-disabled/auto-disabled)
- Integrate toolbar into Table title; wrap content with Card; use Table’s native empty state
- Make “Enable All” conditional (hidden when all keys are enabled), mirroring “Disable All”
- Unify numeric typography (current/total same size) for better readability
- Default page size set to 10; fallback to 10 when backend page_size is absent; page-size options: 10/20/50/100
- Cleanup imports and dead code (remove VChart and pie-spec logic)
- Minor spacing polish (extra bottom margin before table), no footer buttons
2025-08-10 00:55:18 +08:00
t0ng7u a0f48f5a30 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 21:41:33 +08:00
t0ng7u 2177620e68 💄 style(ui): replace inline gradients with reusable pastel blur balls; improve dark mode
- Introduce a global CSS utility `with-pastel-balls` in `web/src/index.css`, rendering pastel “blur balls” via ::before with CSS variables (`--pb1..--pb4`, `--pb-opacity`, `--pb-blur`) for easy theming.
- Apply the utility to pricing header cards and skeletons:
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx`
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx`
- Remove per-component inline `linear-gradient(...)` backgrounds and redundant absolute-positioned decoration nodes to reduce duplication.
- Dark mode:
  - Keep the same pastel palette (pink/lavender/mint/peach).
  - Increase visibility with `--pb-opacity: 0.36`, `--pb-blur: 65px`, and `mix-blend-mode: screen`.
- No functional logic changes; UI-only. Lint passes.

Affected files:
- web/src/index.css
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
2025-08-09 21:40:32 +08:00
CaIon 991a09f47a feat: update Fluent Read link handling in sidebar to improve chat item filtering 2025-08-09 21:06:25 +08:00
CaIon 4abe75faed feat: remove custom JSON marshaling for Message struct 2025-08-09 21:04:49 +08:00
CaIon 5a3f011ac2 feat: enhance JSON marshaling for Message and skip Fluent Read links in chat items 2025-08-09 20:58:28 +08:00
CaIon 4bbdc8e2b2 feat: add verbosity field to OpenAI request #1540 2025-08-09 20:12:27 +08:00
CaIon 4c0c9ffd0a feat: add AWS invoke error handling and new error code 2025-08-09 19:26:41 +08:00
CaIon 20575e798a feat: improve FluentRead notification handling and user prompts 2025-08-09 18:37:08 +08:00
RedwindA f5a52bc5b5 feat: update dto for embeddings 2025-08-09 18:31:56 +08:00
CaIon a5b4c2f7b5 feat: add FluentRead support in chat configuration 2025-08-09 18:26:45 +08:00
RedwindA 1dcb9a01ce Merge branch 'alpha' into 'feat/support-native-gemini-embedding' 2025-08-09 18:05:11 +08:00
CaIon 8e52b42c0a feat: update channel label to indicate deprecation and suggest alternative 2025-08-09 17:51:54 +08:00
CaIon f2dbdbc445 feat: update relay-text to conditionally include usage based on StreamOptions #696 2025-08-09 17:51:49 +08:00
CaIon 8c4f0e6992 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 17:02:20 +08:00
CaIon 2fb3e265f6 feat: enhance TokensPage and useTokensData to support Fluent integration and notifications 2025-08-09 17:02:06 +08:00
t0ng7u 2da040f74c 🧹 Refactor: remove redundant code and simplify renderers
- Users table (UsersColumnDefs.js):
  - Remove unused quota calculations from the status renderer
  - Keep status Tag minimal; tooltip shows request count only
  - No functional changes

- Tokens table (TokensColumnDefs.js):
  - Simplify chats menu parsing from localStorage; remove redundant flags/loops
  - Remove unused variables and console statements
  - Keep error handling via showError; preserve existing operations behavior

- General:
  - Codebase tidying only; no UI/logic changes beyond cleanup
  - Lint passes successfully
2025-08-09 16:51:09 +08:00
t0ng7u 2757d555c7 style(ui): Replace switches with buttons; add quota column with Popover; cleanup
- Tokens/Users tables:
  - Replaced status Switch with explicit Enable/Disable buttons in the operation column
  - Unified button styles with Channels/Models (Disable: danger + small; Enable: default + small)
  - Status column now shows a small Tag only; standardized labels (Enabled/Disabled/etc.); removed usage info

- New "Remaining/Total Quota" column:
  - Wrapped in a white Tag; shows Remaining/Total with a progress bar
  - Replaced Tooltip with Popover; contents use Typography.Paragraph with copyable values
  - Copyable content excludes percentages (only numeric quota values are copied)
  - Added padding to Popover content for better readability

- Tokens specifics:
  - For unlimited quota, show a white Tag "Unlimited quota" with a Popover that displays copyable "Used quota"

- Cleanup:
  - Removed Switch imports/handlers and unused code paths
  - Eliminated console logs and redundant flags; simplified chats parsing
  - Removed quota calculations from status renderers

Files:
- web/src/components/table/tokens/TokensColumnDefs.js
- web/src/components/table/users/UsersColumnDefs.js
2025-08-09 16:47:14 +08:00
t0ng7u 1dd1ef862f 🐛 fix(db): allow re-adding models & vendors after soft delete; add safe index cleanup
Replace legacy single-column unique indexes with composite unique indexes on
(name, deleted_at) and introduce a safe index drop utility to eliminate
duplicate-key errors and noisy MySQL 1091 warnings.

WHAT
• model/model_meta.go
  - Model.ModelName  → `uniqueIndex:uk_model_name,priority:1`
  - Model.DeletedAt  → `index; uniqueIndex:uk_model_name,priority:2`
• model/vendor_meta.go
  - Vendor.Name      → `uniqueIndex:uk_vendor_name,priority:1`
  - Vendor.DeletedAt → `index; uniqueIndex:uk_vendor_name,priority:2`
• model/main.go
  - Add `dropIndexIfExists(table, index)`:
    • Checks `information_schema.statistics`
    • Drops index only when present (avoids Error 1091)
  - Invoke helper in `migrateDB` & `migrateDBFast`
  - Remove direct `ALTER TABLE … DROP INDEX …` calls

WHY
• Users received `Error 1062 (23000)` when re-creating a soft-deleted
  model/vendor because the old unique index enforced uniqueness on name alone.
• Directly dropping nonexistent indexes caused MySQL `Error 1091` noise.

HOW
• Composite unique indexes `(model_name, deleted_at)` / `(name, deleted_at)`
  respect GORM soft deletes.
• Safe helper ensures idempotent migrations across environments.

RESULT
• Users can now delete and re-add the same model or vendor without manual SQL.
• Startup migration runs quietly across MySQL, PostgreSQL, and SQLite.
• No behavior changes for existing data beyond index updates.

TEST
1. Add model “deepseek-chat” → delete (soft) → re-add → success.
2. Add vendor “DeepSeek”     → delete (soft) → re-add → success.
3. Restart service twice → no duplicate key or 1091 errors.
2025-08-09 15:44:08 +08:00
t0ng7u 4886f86cc8 style(JSONEditor): add AGPL-3.0 license header, clean imports & refine Banner UI
* Added full GNU Affero General Public License v3 header at the top of `JSONEditor.js`.
* Removed unused `IconCode` and `IconRefresh` imports to eliminate dead code.
* Set `closeIcon={null}` and applied `!rounded-md` class for `Banner`, improving visual consistency and preventing unintended dismissal.
* Normalized whitespace and line-breaks for better readability and lint compliance.
2025-08-09 14:08:28 +08:00
同語 a08e027bde feat: optimized JSONEditor in duplicate key handling
Merge pull request #1534 from HynoR/feat/je
2025-08-09 13:22:16 +08:00
t0ng7u 92a979751e Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 13:10:20 +08:00
t0ng7u e1796b92cd 🐛 fix(db): allow re-adding models/vendors after soft delete via composite unique indexes
Ensure models and vendors can be re-created after soft deletion by switching to composite unique indexes on (name, deleted_at) and cleaning up legacy single-column unique indexes on MySQL.

Why
- MySQL raised 1062 duplicate key errors when re-adding a soft-deleted model/vendor because the legacy unique index enforced uniqueness on the name column alone (uk_model_name / uk_vendor_name), despite soft deletes.
- Users encountered errors such as:
  - Error 1062 (23000): Duplicate entry 'deepseek-chat' for key 'models.uk_model_name'
  - Error 1062 (23000): Duplicate entry 'DeepSeek' for key 'vendors.uk_vendor_name'

How
- Model indices:
  - model/model_meta.go:
    - Model.ModelName → gorm: uniqueIndex:uk_model_name,priority:1
    - Model.DeletedAt → gorm: index; uniqueIndex:uk_model_name,priority:2
- Vendor indices:
  - model/vendor_meta.go:
    - Vendor.Name → gorm: uniqueIndex:uk_vendor_name,priority:1
    - Vendor.DeletedAt → gorm: index; uniqueIndex:uk_vendor_name,priority:2
- Migration (automatic, idempotent):
  - model/main.go (migrateDB, migrateDBFast):
    - On MySQL, drop legacy single-column unique indexes if present:
      - ALTER TABLE models DROP INDEX uk_model_name;
      - ALTER TABLE vendors DROP INDEX uk_vendor_name;
    - Then run AutoMigrate to create composite unique indexes.
  - Missing-index errors are ignored to keep the migration safe to run multiple times.

Result
- Users can delete and re-add the same model/vendor name without manual SQL.
- Migration runs automatically at startup; no user action required.
- PostgreSQL and SQLite remain unaffected.

Files changed
- model/model_meta.go
- model/vendor_meta.go
- model/main.go (migrateDB, migrateDBFast)

Testing
- Create model "deepseek-chat" → delete (soft) → re-create → succeeds.
- Create vendor "DeepSeek" → delete (soft) → re-create → succeeds.

Backward compatibility
- Data remains intact; only index definitions are updated.
- Behavior is unchanged except for fixing the uniqueness constraint with soft deletes.
2025-08-09 13:07:57 +08:00
CaIon 05af05e449 feat: add system prompt override functionality in channel settings and request handling #1468 2025-08-09 12:53:06 +08:00
Calcium-Ion 2c4dc44661 Merge pull request #1500 from antecanis8/gemini_batchembedcontents
fix: Gemini embedding model only embeds the first text in a batch
2025-08-09 11:42:08 +08:00
Calcium-Ion 7d08e13393 Merge pull request #1429 from feitianbubu/pr/video-preview-modal
feat: add video preview modal
2025-08-09 11:37:19 +08:00
Calcium-Ion 9d6c154609 Merge pull request #1458 from simplty/fix/midjourney-field-compatibility
fix(midjourney): 为 Midjourney 任务添加视频 URL 字段
2025-08-09 11:35:22 +08:00
Calcium-Ion 807ec2003c Merge pull request #1465 from fangzhengjin/alpha
fix: 当OIDC的AuthUrl带有param时,跳转参数拼接错误
2025-08-09 11:34:55 +08:00
Calcium-Ion 91895f4d4c Merge pull request #1536 from Yincmewy/alpha
feats:replace GLM-4v authentication headers to support customize api key
2025-08-09 11:19:22 +08:00
t0ng7u 8d7ef18fc1 📱 feat(ui): enhance mobile pagination in PricingCardView
* Integrate `useIsMobile` hook to detect mobile devices.
* Pagination now automatically:
  * sets `size="small"` on mobile screens
  * enables `showQuickJumper` for quicker navigation on small screens
* Desktop behaviour remains unchanged.
2025-08-09 10:14:35 +08:00
t0ng7u 81a24e9a87 feat: Extend endpoint templates to cover all native endpoints
This commit updates the quick-fill endpoint templates used in the model and pre-fill group editors:

• `EditModelModal.jsx`
• `EditPrefillGroupModal.jsx`

Key changes
1. Added missing default endpoints defined in `common/endpoint_defaults.go`.
   - `openai-response`
   - `gemini`
   - `jina-rerank`
2. Ensured each template entry includes both `path` and `method` for clarity.

Benefits
• Provides one-click access to every built-in upstream endpoint, reducing manual input.
• Keeps the UI definitions in sync with backend defaults, preventing mismatch errors.
2025-08-09 09:33:13 +08:00
t0ng7u 13da9eb20a 🔧 fix(pricing): synchronize group ratio in Table & Card views with sidebar selection
Problem
Choosing a different token-group in the pricing sidebar only updated the filter but did **not** refresh the displayed group ratio in both the Table (`@table/`) and Card (`@card/`) views. The callback used by the sidebar changed `filterGroup` yet left `selectedGroup` untouched, so ratio columns/cards kept showing the previous value.

Solution
• `PricingSidebar.jsx`
  – Accept new prop `handleGroupClick` (from `useModelPricingData`).
  – Forward this callback to `PricingGroups` (`setFilterGroup={handleGroupClick}`) while retaining `setFilterGroup` for reset logic.
  – Keeps both `filterGroup` filtering and `selectedGroup` state in sync via the single unified handler.

Result
Switching groups in the sidebar now simultaneously updates:
1. the model list filtering, and
2. the ratio information shown in both pricing Table and Card views.

No UI/UX regression; linter passes.
2025-08-09 08:58:36 +08:00
t0ng7u 53ac48aeca 🎨 chore(sidebar): swap “Channel Management” and “Model Management” positions in admin menu
The sidebar’s admin section now displays “Channel Management” before “Model Management” to better reflect common user workflows and improve navigation clarity.

Details:
• Updated `web/src/components/layout/SiderBar.js`
  – Re-ordered items in `adminItems` array so `channel` precedes `models`.
• No logic or route changes; this is purely a UI ordering adjustment.

This change enhances usability for administrators by presenting frequently accessed channel settings first.
2025-08-09 08:50:39 +08:00
RedwindA 74057324b2 fix typo; add ParamOverride for Gemini Embedding 2025-08-09 01:07:48 +08:00
RedwindA 8127ad9929 feat: support native Gemini Embedding 2025-08-09 00:27:33 +08:00
Yincmewy e7d91d018d feats:replace GLM-4v authentication headers to support customize api key 2025-08-08 23:26:29 +08:00
HynoR a1b796b7ea feat: optimized Json Visual Editor(JSONEditor) when detected duplicate key 2025-08-08 19:00:02 +08:00
CaIon b3a1948a7b feat: enhance AddAbilities and BatchInsertChannels to support transaction handling 2025-08-08 18:36:09 +08:00
CaIon 9682c46cc4 feat: implement moonshot adaptor for request handling and response processing 2025-08-08 17:28:21 +08:00
Calcium-Ion b49274ea62 Merge pull request #1531 from QuantumNous/claude-code
feat: 完善格式转换,修复gemini渠道和openai渠道在claude code中使用的问题
2025-08-08 16:46:56 +08:00
CaIon 5b5b97eb30 feat: 完善格式抓换,修复gemini渠道和openai渠道在claude code中使用的问题 2025-08-08 16:45:37 +08:00
t0ng7u 9f452e4781 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-08 15:17:23 +08:00
t0ng7u b3b9fb9f93 🔧 refactor(pricing): render “auto” routing chain only when relevant & remove unused prop
Changes
1. ModelPricingTable.jsx
   • Compute `autoChain` as the intersection of `autoGroups` and the model’s `enable_groups` (order preserved).
   • Display the chain banner only when `autoChain.length > 0`; banner shows the reduced path (e.g. `a → c → e`).
   • Dropped obsolete `selectedGroup` prop; all callers updated.

2. ModelDetailSideSheet.jsx / PricingPage.jsx
   • Removed forwarding of deleted `selectedGroup` prop.

Outcome
– “Auto group routing” appears only for models that actually participate in the chain, avoiding empty or irrelevant banners.
– Codebase simplified by eliminating an unused prop.
2025-08-08 15:11:31 +08:00
Calcium-Ion c144bc7f91 Merge pull request #1524 from feitianbubu/pr/fix-last-resp
fix: ensure last message is sent successfully
2025-08-08 14:55:47 +08:00
t0ng7u bd421f471b Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-08 14:50:08 +08:00
t0ng7u 55469a90dc 🚀 feat(pricing): clarify “auto” group routing chain and exclude it from price table
Detailed changes
Backend
• `controller/pricing.go` now includes `auto_groups` in `/api/pricing` response, sourced from `setting.AutoGroups`.

Frontend
• `useModelPricingData.js`
  – Parses `auto_groups` and exposes `autoGroups` state.
• `PricingPage.jsx` → `ModelDetailSideSheet.jsx` → `ModelPricingTable.jsx`
  – Thread `autoGroups` through component tree.
• `ModelPricingTable.jsx`
  – Removes deprecated `getGroupDescription` / `Tooltip`.
  – Filters out `auto` when building price table rows.
  – Renders a descriptive banner: “auto 分组调用链路 → auto → group1 → …”, clarifying fallback order without showing prices.
• Minor i18n tweak: adds `auto分组调用链路` key for the banner text.

Why
Users were confused by the “auto” tag appearing alongside regular groups with no price.
This change:
1. Makes the routing chain explicit.
2. Keeps the pricing table focused on billable groups.

No breaking API changes; existing clients can ignore the new `auto_groups` field.
2025-08-08 14:49:55 +08:00
CaIon f69a4f7153 feat: enhance request handling to support tool calls and improve stream options 2025-08-08 13:47:39 +08:00
CaIon e81113e488 feat: add support for openrouter reasoning efforts in request handling 2025-08-08 13:04:33 +08:00
CaIon b555ac092d fix: playground group 2025-08-08 11:59:04 +08:00
CaIon c833e07ee3 feat: add support for gpt-5 models and adjust temperature settings
- Updated the model list to include various gpt-5 variants.
- Enhanced the ConvertOpenAIRequest function to handle gpt-5 model temperature settings based on specific model prefixes.
- Adjusted default cache and model ratios for new gpt-5 models.
2025-08-08 10:43:07 +08:00
Calcium-Ion a5c2539c38 Merge pull request #1525 from HynoR/chore/gpt5
feat: sync gpt-5 model ratio and support new reasoning effort
2025-08-08 10:24:13 +08:00
Calcium-Ion 047d447163 Merge pull request #1523 from RedwindA/refactor/improve-modelName-wildcard
fix: 修复通配符处理对判断可用渠道的破坏
2025-08-08 10:22:47 +08:00
HynoR 660b129b75 feat: sync gpt-5 model ratio and support new reasoning effort 2025-08-08 09:11:28 +08:00
feitianbubu e434c0c9e3 fix: ensure last message is sent successfully 2025-08-08 08:38:09 +08:00
t0ng7u 85032b3eb8 feat(web): add model prefill group quick-add buttons to channel models selector
- Added support to fetch and render “model prefill groups” in `EditChannelModal.jsx`
- Users can now click a group button to instantly merge that group’s models into the models Select
- Mirrors the prefill-group UX used for tags/endpoints in `EditModelModal.jsx`

Details
- UI/UX:
  - Renders one button per model group inside the models field’s extra actions
  - Clicking a button merges its items into the selected models (trimmed, deduplicated), updating immediately
  - Non-destructive and works alongside existing actions (fill related/all models, fetch upstream, clear, copy)
- API:
  - GET `/api/prefill_group?type=model`
  - Handles `items` as either an array or a JSON string array for robustness
  - If request fails or returns no groups, buttons are simply not shown
- i18n:
  - Reuses existing i18n; group names come from backend and are displayed as-is
- Performance:
  - Simple set merge; negligible overhead
- Backward compatibility:
  - No changes required on the backend or elsewhere; feature is additive
- Testing (manual):
  1) Open channel modal (new or edit) and navigate to the Models section
  2) Confirm model group buttons render when groups are configured
  3) Click a group button → models Select updates with merged models (no duplicates)
  4) Verify other actions (fill related/all, fetch upstream, clear, copy) still work
  5) Close/reopen modal → state resets as expected

Implementation
- `web/src/components/table/channels/modals/EditChannelModal.jsx`
  - Added `modelGroups` state and `fetchModelGroups()` (GET `/api/prefill_group?type=model`)
  - Invoked `fetchModelGroups()` when the modal opens
  - Rendered group buttons in the models Select `extraText`, merging group items into current selection

Chore
- Verified no new linter errors were introduced.
2025-08-08 04:48:18 +08:00
t0ng7u f080bef7b8 💄 ui: adjust column layout in pricing table
- Move model price column to fixed right position
- Convert endpoint types column from fixed to regular column
- Reorder columns: endpoint types now appears before ratio column
- Improve table layout and user experience for pricing data viewing

Changes made to web/src/components/table/model-pricing/view/table/PricingTableColumns.js:
* Removed `fixed: 'right'` from endpointColumn
* Added `fixed: 'right'` to priceColumn
* Updated column order in the columns array
2025-08-08 04:41:46 +08:00
同語 dfcee356d7 🤓feat: the model management module
Merge pull request #1452 from QuantumNous/refactor/model-pricing
2025-08-08 04:23:12 +08:00
t0ng7u 70719d4a43 🐛 fix(model, web): robust JSON handling; remove datatypes dep; stabilize JSONEditor manual mode
- Why:
  - Eliminate `gorm.io/datatypes` for a single field and fix scan errors when drivers return JSON as string.
  - Prevent JSONEditor manual mode from locking on invalid JSON and from appending stray characters after “Fill Template”.

- What:
  - Backend (`model/prefill_group.go`):
    - Replaced `datatypes.JSON` with `JSONValue` (based on `json.RawMessage`) for `PrefillGroup.Items`.
    - Implemented `sql.Scanner` and `driver.Valuer` to accept both `[]byte` and `string`.
    - Implemented `MarshalJSON`/`UnmarshalJSON` to preserve raw JSON in API without base64.
    - Converted comments to Chinese.
  - Frontend (`web/src/components/common/ui/JSONEditor.js`):
    - Added `manualText` buffer for manual mode to avoid input being overridden by external value.
    - Only propagate `onChange` when manual text is valid JSON; otherwise show error but do not block typing.
    - Safe manual-mode rendering: derive rows from `manualText` and avoid calling `split` on non-strings.
    - Improved mode toggle: populate `manualText` from visual data; validate before switching back to visual.
    - Fixed “Fill Template” to sync `manualText`, `jsonData`, and `onChange` to avoid stray trailing characters.

- Impact:
  - Resolves: “unsupported Scan, storing driver.Value type string into type *json.RawMessage”.
  - Resolves: `value.split is not a function` in manual mode.
  - Resolves: extra `s` appended after inserting template.
  - API shape and DB column type remain the same (`gorm:"type:json"`); no `go.mod` changes.
  - Lints pass for modified files.

Files changed:
- model/prefill_group.go
- web/src/components/common/ui/JSONEditor.js
2025-08-08 04:21:50 +08:00
t0ng7u 2da0e1a542 ♻️ refactor(model): replace gorm.io/datatypes with JSONValue for PrefillGroup.Items; fix JSON scan across drivers
- Why:
  - Avoid introducing `gorm.io/datatypes` for a single field.
  - Align with existing pattern (`ChannelInfo`, `Properties`) using `Scanner`/`Valuer`.
  - Fix runtime error when drivers return JSON as string.

- What:
  - Introduced `JSONValue` (based on `json.RawMessage`) implementing `sql.Scanner` and `driver.Valuer`, with `MarshalJSON`/`UnmarshalJSON` to preserve raw JSON in API.
  - Updated `PrefillGroup.Items` to use `JSONValue` with `gorm:"type:json"`.
  - Localized comments in `model/prefill_group.go` to Chinese.

- Impact:
  - Resolves “unsupported Scan, storing driver.Value type string into type *json.RawMessage”.
  - Works with MySQL/Postgres/SQLite whether JSON is returned as `[]byte` or `string`.
  - API and DB schema remain unchanged; no `go.mod` changes; lints pass.

Files changed:
- model/prefill_group.go
2025-08-08 04:09:53 +08:00
t0ng7u 3fc7f7e8f8 ♻️ refactor(model): replace gorm.io/datatypes with json.RawMessage for PrefillGroup.Items
- Why: Avoid adding `gorm.io/datatypes` for a single field; the rest of the codebase does not use it, and using the standard library keeps dependencies lean.
- What:
  - Switched `PrefillGroup.Items` from `datatypes.JSON` to `json.RawMessage`.
  - Updated imports in `model/prefill_group.go` to use `encoding/json` and removed the unused `gorm.io/datatypes`.
  - Preserved `gorm:"type:json"` so DB column behavior remains the same.
- Impact:
  - API response/request shape for `items` remains unchanged (still JSON).
  - DB schema behavior is unchanged; GORM migration continues to handle the field as JSON.
  - No other references to `datatypes` exist; no `go.mod` changes needed.
  - Lints pass for the modified file.

Files changed:
- model/prefill_group.go

No breaking changes.
2025-08-08 03:52:07 +08:00
t0ng7u ec402abba3 🤔 revert: revert go.sum & go.mod version 2025-08-08 03:35:31 +08:00
t0ng7u 41c93f250e 🤔 revert: revert go.sum & go.mod version 2025-08-08 03:22:25 +08:00
t0ng7u f46acac691 🎨 refactor(ui): merge “Content Configuration” into “Basic Information” card in EditPrefillGroupModal
- Move `items` field (`JSONEditor` for endpoint type, `Form.TagInput` otherwise) into the first “Basic Information” card
- Remove the second “Content Configuration” card and its header; consolidate to a single-card layout
- Preserve form initialization, validation, and submit logic; API payload structure remains unchanged
- Improves clarity and reduces visual clutter without altering behavior
- Lint passes

Affected file:
- `web/src/components/table/models/modals/EditPrefillGroupModal.jsx`

No breaking changes.
2025-08-08 03:04:51 +08:00
t0ng7u 908714b73c feat: Improve models UX and robustness: add JSONEditor extraFooter, fix endpoints rendering, and clean up deps
- Why
  - Needed to separate help text from action buttons in JSONEditor for better layout and UX.
  - Models table should robustly render both new object-based endpoint mappings and legacy arrays.
  - Columns should re-render when vendor map changes.
  - Minor import cleanups for consistency.

- What
  - JSONEditor.js
    - Added optional prop extraFooter to render content below the extraText divider.
    - Kept extraText rendered via Divider; extraFooter appears on the next line for clear separation.
  - EditModelModal.jsx
    - Moved endpoint group buttons from extraText into extraFooter to display under the helper text.
    - Kept merge-logic: group items are merged into current endpoints JSON with key override semantics.
    - Consolidated lucide-react imports into a single line.
  - ModelsColumnDefs.js
    - Made endpoint renderer resilient:
      - Supports object-based JSON (keys as endpoint types) and legacy array format.
      - Displays keys/items as tags and limits the number shown; uses stringToColor for visual consistency.
    - Consolidated Semi UI imports into a single line.
  - ModelsTable.jsx
    - Fixed columns memoization dependency to include vendorMap, ensuring re-render when vendor data changes.

- Notes
  - Backward-compatible: extraFooter is additive; existing JSONEditor usage remains unchanged.
  - No API changes to backend.
  - No linter errors introduced.

- Files touched
  - web/src/components/common/ui/JSONEditor.js
  - web/src/components/table/models/modals/EditModelModal.jsx
  - web/src/components/table/models/ModelsColumnDefs.js
  - web/src/components/table/models/ModelsTable.jsx

- Impact
  - Clearer UI for endpoint editing (buttons now below helper text).
  - Correct endpoints display for object-based mappings in models list.
  - More reliable reactivity when vendor data updates.
2025-08-08 02:59:45 +08:00
t0ng7u 68f1a2c329 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-08 02:34:44 +08:00
t0ng7u 5b268d855e feat(pricing+endpoints+ui): wire custom endpoint mapping end‑to‑end and overhaul visual JSON editor
Backend (Go)
- Include custom endpoints in each model’s SupportedEndpointTypes by parsing Model.Endpoints (JSON) and appending keys alongside native endpoint types.
- Build a global supportedEndpointMap map[string]EndpointInfo{path, method} by:
  - Seeding with native defaults.
  - Overriding/adding from models.endpoints (accepts string path → default POST, or {path, method}).
- Expose supported_endpoint at the top level of /api/pricing (vendors-like), removing per-model duplication.
- Fix default path for EndpointTypeOpenAIResponse to /v1/responses.
- Keep concurrency/caching for pricing retrieval intact.

Frontend (React)
- Fetch supported_endpoint in useModelPricingData and propagate to PricingPage → ModelDetailSideSheet → ModelEndpoints.
- ModelEndpoints
  - Resolve path+method via endpointMap; replace {model} with actual model name.
  - Fix mobile visibility; always show path and HTTP method.
- JSONEditor
  - Wrap with Form.Slot to inherit form layout; simplify visual styles.
  - Use Tabs for “Visual” / “Manual” modes.
  - Unify editors: key-value editor now supports nested JSON:
    - “+” to convert a primitive into an object and add nested fields.
    - Add “Convert to value” for two‑way toggle back from object.
    - Stable key rename without reordering rows; new rows append at bottom.
    - Use Row/Col grid for clean alignment; region editor uses Form.Slot + grid.
- Editing flows
  - EditModelModal / EditPrefillGroupModal use JSONEditor (editorType='object') for endpoint mappings.
  - PrefillGroupManagement renders endpoint group items by JSON keys.

Data expectations / compatibility
- models.endpoints should be a JSON object mapping endpoint type → string path or {path, method}. Strings default to POST.
- No schema changes; existing TEXT field continues to store JSON.

QA
- /api/pricing now returns custom endpoint types and global supported_endpoint.
- UI shows both native and custom endpoints; paths/methods render on mobile; nested editing works and preserves order.
2025-08-08 02:34:15 +08:00
RedwindA 037cc47354 feat: optimize channel retrieval by respecting original model names 2025-08-07 21:58:15 +08:00
RedwindA 87cfcf1190 feat: add default model ratio for gemini-2.5-flash-lite-preview-thinking model 2025-08-07 21:39:11 +08:00
RedwindA 2ef6e340a8 feat: update FormatMatchingModelName to handle gemini-2.5-flash-lite model prefix 2025-08-07 21:37:08 +08:00
Calcium-Ion e69b5c45df Merge pull request #1517 from RedwindA/fix/gemini-fetch-models
Fix/gemini-fetch-models
2025-08-07 20:44:34 +08:00
Calcium-Ion cdc6ab61df Merge pull request #1518 from RedwindA/fix/disable-gemini-sse
Fix/disable-gemini-ping
2025-08-07 20:44:24 +08:00
CaIon ef2bed2fe7 feat: enhance Adaptor to support multiple relay modes in request handling 2025-08-07 19:30:42 +08:00
CaIon deb6fbbe21 feat: implement ConvertClaudeRequest method in baidu_v2 Adaptor 2025-08-07 19:19:59 +08:00
t0ng7u 05cf55ac15 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-07 11:09:28 +08:00
t0ng7u 1d28e4ddcb 🎨 feat(models): add row styling for disabled models in ModelsTable
Add visual distinction for enabled/disabled models by applying different
background colors to table rows based on model status. This implementation
follows the same pattern used in ChannelsTable for consistent user experience.

Changes:
- Modified handleRow function in useModelsData.js to include row styling
- Disabled models (status !== 1) now display with gray background using
  --semi-color-disabled-border CSS variable
- Enabled models (status === 1) maintain normal background color
- Preserved existing row click selection functionality

This enhancement improves the visual feedback for users to quickly identify
which models are active vs inactive in the models management interface.
2025-08-07 10:54:05 +08:00
RedwindA 342bf59c91 fix: 调整Disable Ping标志的设置位置 2025-08-07 06:18:22 +08:00
RedwindA b2597206f3 fix a typo in comment 2025-08-07 01:11:01 +08:00
RedwindA d6dea7d082 fix: 修复 FetchUpstreamModels 函数中 AuthHeader 的使用,确保正确处理 多key聚合的情况 2025-08-07 01:01:45 +08:00
RedwindA 14ee6651b4 refactor: 移除 GoogleOpenAI 兼容模型相关结构体,简化 FetchUpstreamModels 函数逻辑 2025-08-07 00:54:48 +08:00
t0ng7u 029768e868 feat(models): Revamp EditModelModal UI and UX
This commit significantly refactors the `EditModelModal` component to streamline the user interface and enhance usability, aligning it with the interaction patterns found elsewhere in the application.

- **Consolidated Layout:** Merged the "Vendor Information" and "Feature Configuration" sections into a single "Basic Information" card. This simplifies the form, reduces clutter, and makes all settings accessible in one view.

- **Improved Prefill Groups:** Replaced the separate `Select` dropdowns for tag and endpoint groups with a more intuitive button-based system within the `extraText` of the `TagInput` components.

- **Additive Button Logic:** The prefill group buttons now operate in an additive mode. Users can click multiple group buttons to incrementally add tags or endpoints, with duplicates being automatically handled.

- **Clear Functionality:** Added "Clear" buttons for both tags and endpoints, allowing users to easily reset the fields.

- **Code Cleanup:** Removed the unused `endpointOptions` constant and unnecessary icon imports (`Building`, `Settings`) to keep the codebase clean.
2025-08-06 03:29:45 +08:00
t0ng7u ac0544614b 🚀 refactor: migrate vendor-count aggregation to model layer & align frontend logic
Summary
• Backend
  – Moved duplicate-name validation and total vendor-count aggregation from controllers (`controller/model_meta.go`, `controller/vendor_meta.go`, `controller/prefill_group.go`) to model layer (`model/model_meta.go`, `model/vendor_meta.go`, `model/prefill_group.go`).
  – Added `GetVendorModelCounts()` and `Is*NameDuplicated()` helpers; controllers now call these instead of duplicating queries.
  – API response for `/api/models` now returns `vendor_counts` with per-vendor totals across all pages, plus `all` summary.
  – Removed redundant checks and unused imports, eliminating `go vet` warnings.

• Frontend
  – `useModelsData.js` updated to consume backend-supplied `vendor_counts`, calculate the `all` total once, and drop legacy client-side counting logic.
  – Simplified initial data flow: first render now triggers only one models request.
  – Deleted obsolete `updateVendorCounts` helper and related comments.
  – Ensured search flow also sets `vendorCounts`, keeping tab badges accurate.

Why
This refactor enforces single-responsibility (aggregation in model layer), delivers consistent totals irrespective of pagination, and removes redundant client queries, leading to cleaner code and better performance.
2025-08-06 01:40:08 +08:00
t0ng7u ef8675c070 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-05 23:19:24 +08:00
t0ng7u cfb5b6024c 🚀 refactor: refine pricing refresh logic & hide disabled models
Summary
-------
1. Pricing generation
   • `model/pricing.go`: skip any model whose `status != 1` when building
     `pricingMap`, ensuring disabled models are never returned to the
     front-end.

2. Cache refresh placement
   • `controller/model_meta.go`
     – Removed `model.RefreshPricing()` from pure read handlers
       (`GetAllModelsMeta`, `SearchModelsMeta`).
     – Kept refresh only in mutating handlers
       (`Create`, `Update`, `Delete`), guaranteeing data is updated
       immediately after an admin change while avoiding redundant work
       on every read.

Result
------
Front-end no longer receives information about disabled models, and
pricing cache refreshes occur exactly when model data is modified,
improving efficiency and consistency.
2025-08-05 23:18:12 +08:00
t0ng7u d7428227f6 feat: enhance soft-delete handling & boost pricing cache performance
Summary
-------
This commit unifies soft-delete behaviour across meta tables and
introduces an in-memory cache for model pricing look-ups to improve
throughput under high concurrency.

Details
-------
Soft-delete consistency
• PrefillGroup / Vendor / Model
  – Added `gorm.DeletedAt` field with `json:"-" gorm:"index"`.
  – Replaced plain `uniqueIndex` with partial unique indexes
    `uniqueIndex:<name>,where:deleted_at IS NULL`
    allowing duplicate keys after logical deletion while preserving
    uniqueness for active rows.
• Imports updated to include `gorm.io/gorm`.
• JSON output now hides `deleted_at`, matching existing tables.

High-throughput pricing cache
• model/pricing.go
  – Added thread-safe maps `modelEnableGroups` & `modelQuotaTypeMap`
    plus RW-mutex for O(1) access.
  – `updatePricing()` now refreshes these maps alongside `pricingMap`.
• model/model_extra.go
  – Rewrote `GetModelEnableGroups` & `GetModelQuotaType` to read from
    the new maps, falling back to automatic refresh via `GetPricing()`.

Misc
• Retained `RefreshPricing()` helper for immediate cache invalidation
  after admin actions.
• All modified files pass linter; no breaking DB migrations required
  (handled by AutoMigrate).

Result
------
– Soft-delete logic is transparent, safe, and allows record “revival”.
– Pricing-related queries are now constant-time, reducing CPU usage and
  latency under load.
2025-08-05 22:26:19 +08:00
antecanis8 4a5bd6938b feat: add support for configuring output dimensionality for multiple Gemini new models 2025-08-04 14:19:19 +00:00
t0ng7u 607b6d4c1d 🐛 fix(models): eliminate vendor column flicker by loading vendors before models
Why:
• The vendor list API is separate from the models API, causing the “Vendor” column in `ModelsTable` to flash (rendering `'-'` first, then updating) after the table finishes loading.
• This visual jump degrades the user experience.

What:
• Updated `web/src/hooks/models/useModelsData.js`
  – In the initial `useEffect`, vendors are fetched first with `loadVendors()` and awaited.
  – Only after vendors are ready do we call `loadModels()`, ensuring `vendorMap` is populated before the table renders.

Outcome:
• The table now renders with complete vendor data on first paint, removing the flicker and providing a smoother UI.
2025-08-04 22:11:13 +08:00
t0ng7u a436f81e1c 💄 fix(pricing-card): align skeleton responsive grid with actual card layout
- Update PricingCardSkeleton grid classes from 'sm:grid-cols-2 lg:grid-cols-3'
  to 'xl:grid-cols-2 2xl:grid-cols-3' to match PricingCardView layout
- Ensures consistent column count between skeleton and actual content
  at same screen sizes
- Improves loading state visual consistency across different breakpoints
2025-08-04 22:03:12 +08:00
t0ng7u 5b55a53b07 🍎 chore: modify the JSONEditor component import path 2025-08-04 21:58:10 +08:00
t0ng7u e64a7520cb Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-04 21:37:38 +08:00
t0ng7u f6b49dce15 🏗️ refactor: Replace model categories with vendor-based filtering and optimize data structure
- **Backend Changes:**
  - Refactor pricing API to return separate vendors array with ID-based model references
  - Remove redundant vendor_name/vendor_icon fields from pricing records, use vendor_id only
  - Add vendor_description to pricing response for frontend display
  - Maintain 1-minute cache protection for pricing endpoint security

- **Frontend Data Flow:**
  - Update useModelPricingData hook to build vendorsMap from API response
  - Enhance model records with vendor info during data processing
  - Pass vendorsMap through component hierarchy for consistent vendor data access

- **UI Component Replacements:**
  - Replace PricingCategories with PricingVendors component for vendor-based filtering
  - Replace PricingCategoryIntro with PricingVendorIntro in header section
  - Remove all model category related components and logic

- **Header Improvements:**
  - Implement vendor intro with real backend data (name, icon, description)
  - Add text collapsible feature (2-line limit with expand/collapse functionality)
  - Support carousel animation for "All Vendors" view with vendor icon rotation

- **Model Detail Modal Enhancements:**
  - Update ModelHeader to use real vendor icons via getLobeHubIcon()
  - Move tags from header to ModelBasicInfo content area to avoid SideSheet title width constraints
  - Display only custom tags from backend with stringToColor() for consistent styling
  - Use Space component with wrap property for proper tag layout

- **Table View Optimizations:**
  - Integrate RenderUtils for description and tags columns
  - Implement renderLimitedItems for tags (max 3 visible, +x popover for overflow)
  - Use renderDescription for text truncation with tooltip support

- **Filter Logic Updates:**
  - Vendor filter shows disabled options instead of hiding when no models match
  - Include "Unknown Vendor" category for models without vendor information
  - Remove all hardcoded vendor descriptions, use real backend data

- **Code Quality:**
  - Fix import paths after component relocation
  - Remove unused model category utilities and hardcoded mappings
  - Ensure consistent vendor data usage across all pricing views
  - Maintain backward compatibility with existing pricing calculation logic

This refactor provides a more scalable vendor-based architecture while eliminating
data redundancy and improving user experience with real-time backend data integration.
2025-08-04 21:36:31 +08:00
antecanis8 ded1cde2ff fix : Gemini embedding model only embeds the first text in a batch 2025-08-04 13:02:57 +00:00
Bliod-Cook 3ed146f239 feat: allow admin to restrict the minimum linuxdo trust level to register 2025-08-04 17:19:38 +08:00
t0ng7u 12b1893287 feat: add model name matching rules with priority-based lookup
Add flexible model name matching system to support different matching patterns:

Backend changes:
- Add `name_rule` field to Model struct with 4 matching types:
  * 0: Exact match (default)
  * 1: Prefix match
  * 2: Contains match
  * 3: Suffix match
- Implement `FindModelByNameWithRule` function with priority order:
  exact > prefix > suffix > contains
- Add database migration for new `name_rule` column

Frontend changes:
- Add "Match Type" column in models table with colored tags
- Add name rule selector in create/edit modal with validation
- Auto-set exact match and disable selection for preconfigured models
- Add explanatory text showing priority order
- Support i18n for all new UI elements

This enables users to define model patterns once and reuse configurations
across similar models, reducing repetitive setup while maintaining exact
match priority for specific overrides.

Closes: #[issue-number]
2025-08-04 16:01:56 +08:00
t0ng7u c504c9af5d 💰 feat: Add model billing type (quota_type) support across backend & frontend
Summary
• Backend
  1. model/model_meta.go
     – Added `QuotaType` field to `Model` struct (JSON only, gorm `-`).
  2. model/model_groups.go
     – Implemented `GetModelQuotaType(modelName)` leveraging cached pricing map.
  3. controller/model_meta.go
     – Enhanced `fillModelExtra` to populate `QuotaType` using new helper.

• Frontend
  1. web/src/components/table/models/ModelsColumnDefs.js
     – Introduced `renderQuotaType` helper that visualises billing mode with coloured tags (`teal = per-call`, `violet = per-token`).
     – Added “计费类型” column (`quota_type`) to models table.

Why
Providing the billing mode alongside existing pricing/group information gives administrators instant visibility into whether each model is priced per call or per token, aligning UI with new backend metadata.

Notes
No database migration required – `quota_type` is transient, delivered via API. Frontend labels/colours can be adjusted via i18n or theme tokens if necessary.
2025-08-04 15:38:01 +08:00
t0ng7u 8816896166 Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-08-04 10:00:27 +08:00
t0ng7u 663e25b311 feat: Add prefill group management system for models
- Add new PrefillGroup model with CRUD operations
  * Support for model, tag, and endpoint group types
  * JSON storage for group items with GORM datatypes
  * Automatic database migration support

- Implement backend API endpoints
  * GET /api/prefill_group - List groups by type with admin auth
  * POST /api/prefill_group - Create new groups
  * PUT /api/prefill_group - Update existing groups
  * DELETE /api/prefill_group/:id - Delete groups

- Add comprehensive frontend management interface
  * PrefillGroupManagement component for group listing
  * EditPrefillGroupModal for group creation/editing
  * Integration with EditModelModal for auto-filling
  * Responsive design with CardTable and SideSheet

- Enhance model editing workflow
  * Tag group selection with auto-fill functionality
  * Endpoint group selection with auto-fill functionality
  * Seamless integration with existing model forms

- Create reusable UI components
  * Extract common rendering utilities to models/ui/
  * Shared renderLimitedItems and renderDescription functions
  * Consistent styling across all model-related components

- Improve user experience
  * Empty state illustrations matching existing patterns
  * Fixed column positioning for operation buttons
  * Item content display with +x indicators for overflow
  * Tooltip support for long descriptions
2025-08-04 02:54:37 +08:00
t0ng7u d70e9a48f1 🚀 feat: expose “Enabled Groups” for models with real-time refresh
Backend
• model/model_meta.go
  – Added `EnableGroups []string` to Model struct
  – fillModelExtra now populates EnableGroups

• model/model_groups.go
  – New helper `GetModelEnableGroups` (reuses Pricing cache)

• model/pricing_refresh.go
  – Added `RefreshPricing()` to force immediate cache rebuild

• controller/model_meta.go
  – `GetAllModelsMeta` & `SearchModelsMeta` call `model.RefreshPricing()` before querying, ensuring groups / endpoints are up-to-date

Frontend
• ModelsColumnDefs.js
  – Added `renderGroups` util and “可用分组” table column displaying color-coded tags

Result
Admins can now see which user groups can access each model, and any ability/group changes are reflected instantly without the previous 1-minute delay.
2025-08-04 00:00:51 +08:00
t0ng7u 2e201a4355 feat: polish “Missing Models” UX & mobile actions layout
Overview
• Re-designed `MissingModelsModal` to align with `ModelTestModal` and deliver a cleaner, paginated experience.
• Improved mobile responsiveness for action buttons in `ModelsActions`.

Details
1. MissingModelsModal.jsx
   • Switched from `List` to `Table` for a more structured view.
   • Added search bar with live keyword filtering and clear icon.
   • Implemented pagination via `MODEL_TABLE_PAGE_SIZE`; auto-resets on search.
   • Dynamic rendering: when no data, show unified Empty state without column header.
   • Enhanced header layout with total-count subtitle and modal corner rounding.
   • Removed unused `Typography.Text` import.

2. ModelsActions.jsx
   • Set “Delete Selected Models” and “Missing Models” buttons to `flex-1 md:flex-initial`, placing them on the same row as “Add Model” on small screens.

Result
The “Missing Models” workflow now offers quicker discovery, a familiar table interface, and full mobile friendliness—without altering API behavior.
2025-08-03 22:51:24 +08:00
t0ng7u 1bd8e2a157 feat(edit-vendor-modal): add icon-library reference link & tidy status switch
Highlights
• Introduced `Typography.Text` link with `IconLink` in `extraText` for the **icon** field, pointing to LobeHub’s full icon catalogue; only “请点击我” is clickable for clarity.
• Added required imports for `Typography` and `IconLink`.
• Removed unnecessary `size="large"` prop from the status `Form.Switch` to align with default form styling.

These tweaks improve user guidance when selecting vendor icons and refine the modal’s visual consistency.
2025-08-03 19:45:58 +08:00
t0ng7u 4a9610e200 ♻️ refactor(models-table): extract reusable renderLimitedItems for list popovers
Introduce a generic `renderLimitedItems` helper within `ModelsColumnDefs.js` to eliminate duplicated logic for list-style columns.

Key changes
• Added `renderLimitedItems` to handle item limiting, “+N” indicator, and popover display.
• Migrated `renderTags`, `renderEndpoints`, and `renderBoundChannels` to use the new helper.
• Removed redundant inline implementations, reducing complexity and improving readability.
• Preserved previous UX: first 3 items shown, overflow accessible via popover.

This refactor streamlines code maintenance and ensures consistent behavior across related columns.
2025-08-03 19:31:29 +08:00
t0ng7u 652506f1b3 Merge branch 'alpha' into refactor/model-pricing 2025-08-02 22:26:40 +08:00
t0ng7u 8ffa48ab34 feat(ui): enhance tag input
1. EditModelModal quality-of-life
   • Added comma parsing to `Form.TagInput`; users can now paste
     `tag1, tag2 , tag3` to bulk-create tags.
   • Updated placeholder copy to reflect the new capability.

All files pass linting; no runtime changes outside the intended UI updates.
2025-08-01 03:00:12 +08:00
t0ng7u e3d98a3f5b 🎨 style(sidebar): unify highlight color & assign unique icon for Models
• Removed obsolete `sidebarIconColors` map and `getItemColor` util from
  SiderBar/render; all selected states now use the single CSS variable
  `--semi-color-primary` for both text and icons.
• Simplified `getLucideIcon`:
  – Added `Package` to Lucide imports.
  – Switched “models” case to `<Package />`, avoiding duplication with
    the Layers glyph.
  – Replaced per-key color logic with `iconColor` derived from the new
    uniform highlight color.
• Stripped any unused imports / dead code paths after the refactor.
• Lint passes; sidebar hover/focus behavior unchanged while visual
  consistency is improved.
2025-08-01 02:50:06 +08:00
t0ng7u 1f35f66820 ♻️ refactor(hooks/models): deduplicate useModelsData and optimize vendor-tab counts
Highlights
──────────
1. Removed code duplication
   • Introduced `extractItems` helper to safely unwrap API payloads.
   • Simplified `getFormValues` to a single-line fallback expression.
   • Replaced repeated list-extraction code in `loadModels`, `searchModels`,
     and `refreshVendorCounts` with the new helper.

2. Vendor tab accuracy & performance
   • Added `refreshVendorCounts` to recalc counts via a single lightweight
     request; invoked only when必要 (current tab ≠ "all“) to avoid redundancy.
   • `loadModels` still updates counts instantly when viewing "all", ensuring
     accurate numbers on initial load and page changes.

3. Misc clean-ups
   • Streamlined conditional URL building and state updates.
   • Confirmed all async branches include error handling with i18n messages.
   • Ran linter → zero issues.

Result: leaner, easier-to-maintain hook with correct, real-time vendor counts
and no repeated logic.
2025-08-01 02:39:12 +08:00
t0ng7u b0fe72910f 🐛 fix(model): preserve created_time on Model update and streamline field maintenance
The update operation for Model previously overwrote `created_time` with zero
because GORM included every struct field in the UPDATE statement.
This commit adjusts `Model.Update()` to:

* Call `Omit("created_time")` so the creation timestamp is never modified.
* Refresh `UpdatedTime` with `common.GetTimestamp()` before persisting.
* Delegate the remainder of the struct to GORM, eliminating the need to
  maintain an explicit allow-list whenever new fields are introduced.

No API contract is changed; existing CRUD endpoints continue to work
normally while data integrity for historical records is now guaranteed.
2025-08-01 02:21:14 +08:00
t0ng7u ad4de7aaef 🔄 fix: improve vendor-tab filtering & counts, resolve SQL ambiguity, and reload data correctly
Backend
• model/model_meta.go
  – Import strconv
  – SearchModels: support numeric vendor ID filter vs. fuzzy name search
  – Explicitly order by `models.id` to avoid “ambiguous column name: id” error

Frontend
• hooks/useModelsData.js
  – Change vendor-filter API to pass vendor ID
  – Automatically reload models when `activeVendorKey` changes
  – Update vendor counts only when viewing “All” to preserve other tab totals
• Add missing effect in EditModelModal to refresh vendor list only when modal visible
• Other minor updates to keep lints clean

Result
Tabs now:
1. Trigger API requests on click
2. Show accurate per-vendor totals
3. Filter models without resetting other counts
Backend search handles both vendor IDs and names without SQL errors.
2025-07-31 23:30:45 +08:00
t0ng7u 8ec0f0a2bc Merge branch 'alpha' into refactor/model-pricing 2025-07-31 22:28:59 +08:00
t0ng7u 1baad070d7 🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements
Backend
• Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps
• Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go`
• Auto-migrate new tables in DB startup logic

Frontend
• Build complete “Model Management” module under `/console/models`
  - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs
  - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile`
• Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature
• Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes

Table UX improvements
• Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style)
• Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags
• Color all tags deterministically using `stringToColor` for consistent theming
• Change vendor column tag color to white for better contrast

Misc
• Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up

These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
2025-07-31 22:28:09 +08:00
t0ng7u b243fce7ec Merge branch 'alpha' into refactor/model-pricing 2025-07-31 00:41:01 +08:00
ZhengJin 894ddf3fa1 Update api.js 2025-07-28 17:52:59 +08:00
simplty 8f484651e3 feat(midjourney): 为 Midjourney 任务添加视频 URL 字段
新增了对 Midjourney 任务中 VideoUrl 和 VideoUrls 字段的映射和更新检查。
这确保了 Midjourney 生成的视频 URL 能够被正确地存储,并且任务更新能够反映这些 URL 的变化,提高了数据同步的准确性。
2025-07-27 16:20:48 +08:00
t0ng7u 8d38cb8663 Merge branch 'alpha' into refactor/model-pricing 2025-07-27 09:51:35 +08:00
t0ng7u 1fb829ddaa Merge branch 'alpha' into refactor/model-pricing 2025-07-27 00:03:18 +08:00
t0ng7u 017f32b978 refactor: pricing filters for dynamic counting & cleaner logic
This commit introduces a unified, maintainable solution for all model-pricing filter buttons and removes redundant code.

Key points
• Added `usePricingFilterCounts` hook
  - Centralises filtering logic and returns:
    - `quotaTypeModels`, `endpointTypeModels`, `dynamicCategoryCounts`, `groupCountModels`
  - Keeps internal helpers private (removed public `modelsAfterCategory`).

• Updated components to consume the new hook
  - `PricingSidebar.jsx`
  - `FilterModalContent.jsx`

• Improved button UI/UX
  - `SelectableButtonGroup.jsx` now respects `item.disabled` and auto-disables when `tagCount === 0`.
  - `PricingGroups.jsx` counts models per group (after all other filters) and disables groups with zero matches.
  - `PricingEndpointTypes.jsx` enumerates all endpoint types, computes filtered counts, and disables entries with zero matches.

• Removed obsolete / duplicate calculations and comments to keep components lean.

The result is consistent, real-time tag counts across all filter groups, automatic disabling of unavailable options, and a single source of truth for filter computations, making future extensions straightforward.
2025-07-26 18:38:18 +08:00
t0ng7u 2dd99898ca Merge branch 'alpha' into refactor/model-pricing 2025-07-26 17:21:47 +08:00
t0ng7u 1327404e35 refactor: Restructure model pricing components and improve UX consistency
- **Fix SideSheet double-click issue**: Remove early return for null modelData to prevent rendering blockage during async state updates
- **Component modularization**:
  - Split ModelDetailSideSheet into focused sub-components (ModelHeader, ModelBasicInfo, ModelEndpoints, ModelPricingTable)
  - Refactor PricingFilterModal with FilterModalContent and FilterModalFooter components
  - Remove unnecessary FilterSection wrapper for cleaner interface
- **Improve visual consistency**:
  - Unify avatar/icon logic between ModelHeader and PricingCardView components
  - Standardize tag colors across all pricing components (violet/teal for billing types)
  - Apply consistent dashed border styling using Semi UI theme colors
- **Enhance data accuracy**:
  - Display raw endpoint type names (e.g., "openai", "anthropic") instead of translated descriptions
  - Remove text alignment classes for better responsive layout
  - Add proper null checks to prevent runtime errors
- **Code quality improvements**:
  - Reduce component complexity by 52-74% through modularization
  - Improve maintainability with single responsibility principle
  - Add comprehensive error handling for edge cases

This refactoring improves component reusability, reduces bundle size, and provides a more consistent user experience across the model pricing interface.
2025-07-26 04:24:22 +08:00
t0ng7u b04fd36c53 Merge branch 'alpha' into refactor/model-pricing 2025-07-25 20:33:14 +08:00
t0ng7u d351c61606 🍭 style(ui): Optimize style layout and improve responsive layout 2025-07-24 23:28:55 +08:00
feitianbubu f11236a30e feat: add video preview modal 2025-07-24 19:34:21 +08:00
t0ng7u 27fffe0c5e Merge remote-tracking branch 'origin/alpha' into refactor/model-pricing 2025-07-24 17:47:08 +08:00
t0ng7u b935068b1f 📱 fix(ui): adjust responsive breakpoints for pricing card grid layout
Optimize grid column breakpoints to account for 460px sidebar width:
- Change from sm:grid-cols-2 lg:grid-cols-3 to xl:grid-cols-2 2xl:grid-cols-3
- Ensures adequate space for card display after subtracting sidebar width
- Improves layout on medium-sized screens where previous breakpoints caused cramped display

Breakpoint calculation:
- 1280px screen - 460px sidebar = 820px → 2 columns
- 1536px screen - 460px sidebar = 1076px → 3 columns
2025-07-24 17:44:48 +08:00
t0ng7u 4116111b57 ♻️ refactor(utils): optimize resetPricingFilters function for better maintainability (#1365)
- Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration
- Replace verbose type checks with optional chaining operator (?.) for cleaner code
- Eliminate redundant function type validations and comments
- Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality
- Improve code readability and follow modern JavaScript best practices

This refactoring enhances code quality without changing the function's behavior,
making it easier to maintain and modify default filter values in the future.
2025-07-24 17:22:20 +08:00
t0ng7u 16b2d0f1bf ♻️ refactor(utils): optimize resetPricingFilters function for better maintainability (#1365)
- Extract default values to DEFAULT_PRICING_FILTERS constant for centralized configuration
- Replace verbose type checks with optional chaining operator (?.) for cleaner code
- Eliminate redundant function type validations and comments
- Reduce code lines by ~50% (from 60 to 25 lines) while maintaining full functionality
- Improve code readability and follow modern JavaScript best practices

This refactoring enhances code quality without changing the function's behavior,
making it easier to maintain and modify default filter values in the future.
2025-07-24 17:15:28 +08:00
t0ng7u 0ff0027aa6 ♻️ Refactor: Move token unit toggle from table header to filter settings
- Remove K/M switch from model price column header in pricing table
- Add "Display in K units" option to pricing display settings panel
- Update parameter passing for tokenUnit and setTokenUnit across components:
  - PricingDisplaySettings: Add tokenUnit toggle functionality
  - PricingSidebar: Pass tokenUnit props to display settings
  - PricingFilterModal: Include tokenUnit in mobile filter modal
- Enhance resetPricingFilters utility to reset token unit to default 'M'
- Clean up PricingTableColumns by removing unused setTokenUnit parameter
- Add English translation for "按K显示单位" as "Display in K units"

This change improves UX by consolidating all display-related controls
in the filter settings panel, making the interface more organized and
the token unit setting more discoverable alongside other display options.

Affected components:
- PricingTableColumns.js
- PricingDisplaySettings.jsx
- PricingSidebar.jsx
- PricingFilterModal.jsx
- PricingTable.jsx
- utils.js (resetPricingFilters)
- en.json (translations)
2025-07-24 17:10:08 +08:00
t0ng7u b3a05d2bbb 🖼️ style(ui): change skeleton button size to 16*16 2025-07-24 03:29:48 +08:00
t0ng7u 8462931253 feat: Add endpoint type filter to model pricing system
- Create PricingEndpointTypes.jsx component for endpoint type filtering
- Add filterEndpointType state management in useModelPricingData hook
- Integrate endpoint type filtering logic in filteredModels computation
- Update PricingSidebar.jsx to include endpoint type filter component
- Update PricingFilterModal.jsx to support endpoint type filtering on mobile
- Extend resetPricingFilters utility function to include endpoint type reset
- Support filtering models by endpoint types (OpenAI, Anthropic, Gemini, etc.)
- Display model count for each endpoint type with localized labels
- Ensure filter state resets to first page when endpoint type changes

This enhancement allows users to filter models by their supported endpoint types,
providing more granular control over model selection in the pricing interface.
2025-07-24 03:25:57 +08:00
t0ng7u f19f1aecc0 💄 style(pricing): enhance card view UI and skeleton loading experience (#1365)
- Increase skeleton card count from 6 to 10 for better visual coverage
- Extend minimum skeleton display duration from 500ms to 1000ms for smoother UX
- Add circle shape to all pricing tags for consistent rounded design
- Apply circle styling to billing type, popularity, endpoint, and context tags

This commit improves the visual consistency and user experience of the pricing
card view by standardizing tag appearance and optimizing skeleton loading timing.
2025-07-24 03:19:32 +08:00
t0ng7u 7b04596b7f Merge branch 'alpha' into refactor/model-pricing 2025-07-24 01:35:59 +08:00
t0ng7u 68e61b407d ♻️ refactor(model-pricing): improve table UI and optimize code structure (#1365)
- Replace model count with group ratio display (x2.2, x1) in group filter
- Remove redundant "Available Groups" column from pricing table
- Remove "Availability" column and related logic completely
- Move "Supported Endpoint Types" column to fixed right position
- Clean up unused parameters and variables in PricingTableColumns.js
- Optimize variable declarations (let → const) and simplify render logic
- Improve code readability and reduce memory allocations

This refactor enhances user experience by:
- Providing clearer group ratio information in filters
- Simplifying table layout while maintaining essential functionality
- Improving performance through better code organization

Breaking changes: None
2025-07-23 11:20:55 +08:00
t0ng7u 024dcda92a 🔧 fix: filter out empty string group from pricing groups selector (#1365)
Filter out the special empty string group ("": "用户分组") from the
usable groups in PricingGroups component. This empty group represents
"user's current group" but contains no data and should not be displayed
in the group filter options.

- Add filter condition to exclude empty string keys from usableGroup
- Prevents displaying invalid empty group option in UI
- Improves user experience by showing only valid selectable groups
2025-07-23 10:04:32 +08:00
t0ng7u 3ae8c74160 feat: Add skeleton loading animation to SelectableButtonGroup component (#1365)
Add comprehensive loading state support with skeleton animations for the SelectableButtonGroup component, improving user experience during data loading.

Key Changes:
- Add loading prop to SelectableButtonGroup with minimum 500ms display duration
- Implement skeleton buttons with proper Semi-UI Skeleton wrapper and active animation
- Use fixed skeleton count (6 items) to prevent visual jumping during load transitions
- Pass loading state through all pricing filter components hierarchy:
  - PricingSidebar and PricingFilterModal as container components
  - PricingDisplaySettings, PricingCategories, PricingGroups, PricingQuotaTypes as filter components

Technical Details:
- Reference CardTable.js implementation for consistent skeleton UI patterns
- Add useEffect hook for 500ms minimum loading duration control
- Support both checkbox and regular button skeleton modes
- Maintain responsive layout compatibility (mobile/desktop)
- Add proper JSDoc parameter documentation for loading prop

Fixes:
- Prevent skeleton count sudden changes that caused visual discontinuity
- Ensure proper skeleton animation with Semi-UI active parameter
- Maintain consistent loading experience across all filter components
2025-07-23 04:31:27 +08:00
t0ng7u 6eaaee2052 feat: enhance SelectableButtonGroup with checkbox support and refactor pricing display settings (#1365)
- Add withCheckbox prop to SelectableButtonGroup component for checkbox-prefixed buttons
- Support both single value and array activeValue for multi-selection scenarios
- Refactor PricingDisplaySettings to use consistent SelectableButtonGroup styling
- Replace Switch components with checkbox-enabled SelectableButtonGroup
- Replace Select dropdown with SelectableButtonGroup for currency selection
- Maintain unified UI/UX across all pricing filter components
- Add proper JSDoc documentation for new withCheckbox functionality

This improves visual consistency and provides a more cohesive user experience
in the model pricing filter interface.
2025-07-23 04:10:44 +08:00
t0ng7u e007b26b8a 💄 feat(ui): replace availability indicators with icons in PricingTableColumns (#1365)
Summary
• Swapped out the old availability UI for clearer icon-based feedback.
• Users now see a green check icon when their group can use a model and a red × icon (with tooltip) when it cannot.

Details
1. Imports
   • Removed deprecated `IconVerify`.
   • Added `IconCheckCircleStroked`  and `IconClose`  for new states.

2. Availability column
   • `renderAvailable` now
     – Shows a green `IconCheckCircleStroked` inside a popover (“Your group can use this model”).
     – Shows a red `IconClose` inside a popover (“你的分组无权使用该模型”) when the model is inaccessible.
     – Eliminates the empty cell/grey tag fallback.

3. Group tag
   • Updated selected-group tag to use `IconCheckCircleStroked` for visual consistency.

Result
Improves UX by providing explicit visual cues for model availability and removes ambiguous blank cells.
2025-07-23 03:41:19 +08:00
t0ng7u e22ef769cb ♻️ refactor(model-pricing): extract resetPricingFilters utility and eliminate duplication (#1365)
Centralize filter-reset logic to improve maintainability and consistency.

- Add `resetPricingFilters` helper to `web/src/helpers/utils.js`, encapsulating all reset actions (search, category, currency, ratio, group, quota type, etc.).
- Update `PricingFilterModal.jsx` and `PricingSidebar.jsx` to import and use the new utility instead of keeping their own duplicate `handleResetFilters`.
- Removes repeated code, ensures future changes to reset behavior require modification in only one place, and keeps components lean.
2025-07-23 03:29:11 +08:00
t0ng7u 9bab77ad05 🔧 refactor(pricing-filters): extract display settings & improve mobile layout (#1365)
* **PricingDisplaySettings.jsx**
  • Extracted display settings (recharge price, currency, ratio toggle) from PricingSidebar
  • Maintains complete styling and functionality as standalone component

* **SelectableButtonGroup.jsx**
  • Added isMobile detection with conditional Col spans
  • Mobile: `span={12}` (2 buttons per row) for better touch experience
  • Desktop: preserved responsive grid `xs={24} sm={24} md={24} lg={12} xl={8}`

* **PricingSidebar.jsx**
  • Updated imports to use new PricingDisplaySettings component
  • Simplified component structure while preserving reset logic

These changes enhance code modularity and provide optimized mobile UX for filter button groups across the pricing interface.
2025-07-23 03:14:25 +08:00
t0ng7u cff8c3ac55 📌 fix(pricing-search): make search bar sticky within PricingContent (#1365)
* Added `position: sticky; top: 0; z-index: 5;` to search bar container
  – keeps the bar fixed while the table body scrolls
* Preserves previous padding, border and background styles
* Improves usability by ensuring quick access to search & actions during long list navigation

• PricingTable
  • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}`
  • Processes columns to remove `fixed` in compact mode

• PricingPage & index.css
  • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content`

• Responsive / style refinements
  • Sidebar width adjusted to 460px
  • Scrollbars hidden uniformly across pricing modules

These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability.
2025-07-23 02:28:43 +08:00
t0ng7u f845a96f97 feat(ui): enhance pricing table & filters with responsive button-group, fixed column, scroll tweaks (#1365)
• SelectableButtonGroup
  • Added optional collapsible support with gradient mask & toggle
  • Dynamic tagCount badge support for groups / quota types
  • Switched to responsive Row/Col (`xs 24`, `sm 24`, `lg 12`, `xl 8`) for fluid layout
  • Shows expand button only when item count exceeds visible rows

• Sidebar filters
  • PricingGroups & PricingQuotaTypes now pass tag counts to button-group
  • Counts derived from current models & quota_type

• PricingTableColumns
  • Moved “Availability” column to far right; fixed via `fixed: 'right'`
  • Re-ordered columns and preserved ratio / price logic

• PricingTable
  • Added `compactMode` prop; strips fixed columns and sets `scroll={compactMode ? undefined : { x: 'max-content' }}`
  • Processes columns to remove `fixed` in compact mode

• PricingPage & index.css
  • Added `.pricing-scroll-hide` utility to hide Y-axis scrollbar for `Sider` & `Content`

• Responsive / style refinements
  • Sidebar width adjusted to 460px
  • Scrollbars hidden uniformly across pricing modules

These changes complete the model-pricing UI refactor, ensuring clean scrolling, responsive filters, and fixed availability column for better usability.
2025-07-23 02:23:25 +08:00
t0ng7u 1c22e03a40 🎨 feat(model-pricing): refactor layout and component structure (#1365)
* Re-architected model-pricing page into modular components:
  * PricingPage / PricingSidebar / PricingContent
  * Removed obsolete `ModelPricing*` components and column defs
* Introduced reusable `SelectableButtonGroup` in `common/ui`
  * Supports Row/Col grid (3 per row)
  * Optional collapsible mode with gradient mask & toggle
* Rebuilt filter panels with the new button-group:
  * Model categories, token groups, and quota types
  * Added dynamic `tagCount` badges to display item totals
* Extended `useModelPricingData` hook
  * Added `filterGroup` and `filterQuotaType` state and logic
* Updated PricingTable columns & sidebar reset logic to respect new states
* Ensured backward compatibility via re-export in `index.jsx`
* Polished styling, icons and i18n keys
2025-07-23 01:58:51 +08:00
feitianbubu 9b051199b0 feat: add ali qwen channel autoDisabled 2025-07-19 21:19:46 +08:00
feitianbubu dbced40e01 feat: add kling video text2Video when image is empty 2025-07-19 10:16:31 +08:00
Xyfacai b30cf70c91 feat: dalle 格式支持自定义参数 2025-06-09 22:14:51 +08:00
lollipopkit🏳️‍⚧️ a7e02f8961 Merge branch 'QuantumNous:main' into main 2025-06-05 18:26:43 +08:00
wzxjohn 0a85846098 feat: support UMAMI analytics 2025-05-16 16:44:47 +08:00
lollipopkit🏳️‍⚧️ 9dc153eda1 feat: /api/token/usage 2025-04-29 17:13:28 +08:00
745 changed files with 100206 additions and 25514 deletions
+2 -1
View File
@@ -5,4 +5,5 @@
.gitignore
Makefile
docs
.eslintcache
.eslintcache
.gocache
+5 -4
View File
@@ -47,7 +47,7 @@
# 所有请求超时时间,单位秒,默认为0,表示不限制
# RELAY_TIMEOUT=0
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=120
# STREAMING_TIMEOUT=300
# Gemini 识别图片 最大图片数量
# GEMINI_VISION_MAX_IMAGE_NUM=16
@@ -56,8 +56,6 @@
# SESSION_SECRET=random_string
# 其他配置
# 渠道测试频率(单位:秒)
# CHANNEL_TEST_FREQUENCY=10
# 生成默认token
# GENERATE_DEFAULT_TOKEN=false
# Cohere 安全设置
@@ -65,10 +63,13 @@
# 是否统计图片token
# GET_MEDIA_TOKEN=true
# 是否在非流(stream=false)情况下统计图片token
# GET_MEDIA_TOKEN_NOT_STREAM=true
# GET_MEDIA_TOKEN_NOT_STREAM=false
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# LinuxDo相关配置
LINUX_DO_TOKEN_ENDPOINT=https://connect.linux.do/oauth2/token
LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
# 节点类型
# 如果是主节点则为master
+26
View File
@@ -0,0 +1,26 @@
---
name: Bug Report
about: Describe the issue you encountered with clear and detailed language
title: ''
labels: bug
assignees: ''
---
**Routine Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues currently
+ [ ] I have confirmed I have upgraded to the latest version
+ [ ] I have thoroughly read the project README, especially the FAQ section
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
**Issue Description**
**Steps to Reproduce**
**Expected Result**
**Related Screenshots**
If none, please delete this section.
@@ -0,0 +1,22 @@
---
name: Feature Request
about: Describe the new feature you would like to add with clear and detailed language
title: ''
labels: enhancement
assignees: ''
---
**Routine Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues currently
+ [ ] I have confirmed I have upgraded to the latest version
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
**Feature Description**
**Use Case**
@@ -13,7 +13,3 @@
### PR 描述
**请在下方详细描述您的 PR,包括目的、实现细节等。**
### **重要提示**
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**
+108 -19
View File
@@ -11,19 +11,42 @@ on:
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
build_single_arch:
name: Build & push (${{ matrix.arch }}) [native]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
- name: Check out (shallow)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Save version info
- name: Determine alpha version
id: version
run: |
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
echo "$VERSION" > VERSION
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Publishing version: $VERSION for ${{ matrix.arch }}"
- name: Normalize GHCR repository
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
@@ -31,32 +54,98 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=alpha
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
tags: |
calciumion/new-api:alpha-${{ matrix.arch }}
calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
create_manifests:
name: Create multi-arch manifests (Docker Hub + GHCR)
needs: [build_single_arch]
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out (shallow)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Normalize GHCR repository
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Determine alpha version
id: version
run: |
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & push manifest (Docker Hub - alpha)
run: |
docker buildx imagetools create \
-t calciumion/new-api:alpha \
calciumion/new-api:alpha-amd64 \
calciumion/new-api:alpha-arm64
- name: Create & push manifest (Docker Hub - versioned alpha)
run: |
docker buildx imagetools create \
-t calciumion/new-api:${VERSION} \
calciumion/new-api:${VERSION}-amd64 \
calciumion/new-api:${VERSION}-arm64
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create & push manifest (GHCR - alpha)
run: |
docker buildx imagetools create \
-t ghcr.io/${GHCR_REPOSITORY}:alpha \
ghcr.io/${GHCR_REPOSITORY}:alpha-amd64 \
ghcr.io/${GHCR_REPOSITORY}:alpha-arm64
- name: Create & push manifest (GHCR - versioned alpha)
run: |
docker buildx imagetools create \
-t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64
+104 -22
View File
@@ -1,26 +1,46 @@
name: Publish Docker image (Multi Registries)
name: Publish Docker image (Multi Registries, native amd64+arm64)
on:
push:
tags:
- '*'
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
build_single_arch:
name: Build & push (${{ matrix.arch }}) [native]
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
- name: Check out (shallow)
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Save version info
- name: Resolve tag & write VERSION
run: |
git describe --tags > VERSION
git fetch --tags --force --depth=1
TAG=${GITHUB_REF#refs/tags/}
echo "TAG=$TAG" >> $GITHUB_ENV
echo "$TAG" > VERSION
echo "Building tag: $TAG for ${{ matrix.arch }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# - name: Normalize GHCR repository
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -31,26 +51,88 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Log in to GHCR
# uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
# ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: |
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
calciumion/new-api:latest-${{ matrix.arch }}
# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
create_manifests:
name: Create multi-arch manifests (Docker Hub)
needs: [build_single_arch]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Extract tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
#
# - name: Normalize GHCR repository
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & push manifest (Docker Hub - version)
run: |
docker buildx imagetools create \
-t calciumion/new-api:${TAG} \
calciumion/new-api:${TAG}-amd64 \
calciumion/new-api:${TAG}-arm64
- name: Create & push manifest (Docker Hub - latest)
run: |
docker buildx imagetools create \
-t calciumion/new-api:latest \
calciumion/new-api:latest-amd64 \
calciumion/new-api:latest-arm64
# ---- GHCR ----
# - name: Log in to GHCR
# uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GITHUB_TOKEN }}
# - name: Create & push manifest (GHCR - version)
# run: |
# docker buildx imagetools create \
# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64
#
# - name: Create & push manifest (GHCR - latest)
# run: |
# docker buildx imagetools create \
# -t ghcr.io/${GHCR_REPOSITORY}:latest \
# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \
# ghcr.io/${GHCR_REPOSITORY}:latest-arm64
+141
View File
@@ -0,0 +1,141 @@
name: Build Electron App
on:
push:
tags:
- '*' # Triggers on version tags like v1.0.0
- '!*-*' # Ignore pre-release tags like v1.0.0-beta
- '!*-alpha*' # Ignore alpha tags like v1.0.0-alpha
workflow_dispatch: # Allows manual triggering
jobs:
build:
strategy:
matrix:
# os: [macos-latest, windows-latest]
os: [windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '>=1.25.1'
- name: Build frontend
env:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
# - name: Build Go binary (macos/Linux)
# if: runner.os != 'Windows'
# run: |
# go mod download
# go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
- name: Build Go binary (Windows)
if: runner.os == 'Windows'
run: |
go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe
- name: Update Electron version
run: |
cd electron
VERSION=$(git describe --tags)
VERSION=${VERSION#v} # Remove 'v' prefix if present
# Convert to valid semver: take first 3 components and convert rest to prerelease format
# e.g., 0.9.3-patch.1 -> 0.9.3-patch.1
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
REST=${BASH_REMATCH[4]}
VERSION="$MAJOR.$MINOR.$PATCH"
# If there's extra content, append it without adding -dev
if [[ -n "$REST" ]]; then
VERSION="$VERSION$REST"
fi
fi
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Install Electron dependencies
run: |
cd electron
npm install
# - name: Build Electron app (macOS)
# if: runner.os == 'macOS'
# run: |
# cd electron
# npm run build:mac
# env:
# CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing
- name: Build Electron app (Windows)
if: runner.os == 'Windows'
run: |
cd electron
npm run build:win
# - name: Upload artifacts (macOS)
# if: runner.os == 'macOS'
# uses: actions/upload-artifact@v4
# with:
# name: macos-build
# path: |
# electron/dist/*.dmg
# electron/dist/*.zip
- name: Upload artifacts (Windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: windows-build
path: |
electron/dist/*.exe
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: |
windows-build/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-59
View File
@@ -1,59 +0,0 @@
name: Linux Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend (amd64)
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-51
View File
@@ -1,51 +0,0 @@
name: macOS Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,21 +0,0 @@
name: Check PR Branching Strategy
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-branching-strategy:
runs-on: ubuntu-latest
steps:
- name: Enforce branching strategy
run: |
if [[ "${{ github.base_ref }}" == "main" ]]; then
if [[ "${{ github.head_ref }}" != "alpha" ]]; then
echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
exit 1
fi
elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
exit 1
fi
echo "Branching strategy check passed."
+142
View File
@@ -0,0 +1,142 @@
name: Release (Linux, macOS, Windows)
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
linux:
name: Linux Release
runs-on: ubuntu-latest
steps:
- name: Checkout
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
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.25.1'
- name: Build Backend (amd64)
run: |
go mod download
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
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
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
new-api-*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
macos:
name: macOS Release
runs-on: macos-latest
steps:
- name: Checkout
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
- name: Build Frontend
env:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.25.1'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-macos-*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
windows:
name: Windows Release
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
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
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.25.1'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-*.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+91
View File
@@ -0,0 +1,91 @@
name: Sync Release to Gitee
permissions:
contents: read
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Release Tag to sync (e.g. v1.0.0)'
required: true
type: string
# 配置你的 Gitee 仓库信息
env:
GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名
GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名
jobs:
sync-to-gitee:
runs-on: sync
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Info
id: release_info
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.inputs.tag_name }}
run: |
# 获取 release 信息
RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish)
RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name')
TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish')
# 使用多行字符串输出
{
echo "release_name=$RELEASE_NAME"
echo "target_commitish=$TARGET_COMMITISH"
echo "release_body<<EOF"
echo "$RELEASE_INFO" | jq -r '.body'
echo "EOF"
} >> $GITHUB_OUTPUT
# 下载 release 的所有附件
gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download"
# 列出下载的文件
ls -la ./release_assets/ || echo "No assets directory"
- name: Create Gitee Release
id: create_release
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
with:
gitee_action: create_release
gitee_owner: ${{ env.GITEE_OWNER }}
gitee_repo: ${{ env.GITEE_REPO }}
gitee_token: ${{ secrets.GITEE_TOKEN }}
gitee_tag_name: ${{ github.event.inputs.tag_name }}
gitee_release_name: ${{ steps.release_info.outputs.release_name }}
gitee_release_body: ${{ steps.release_info.outputs.release_body }}
gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }}
- name: Upload Assets to Gitee
if: hashFiles('release_assets/*') != ''
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
with:
gitee_action: upload_asset
gitee_owner: ${{ env.GITEE_OWNER }}
gitee_repo: ${{ env.GITEE_REPO }}
gitee_token: ${{ secrets.GITEE_TOKEN }}
gitee_release_id: ${{ steps.create_release.outputs.release-id }}
gitee_upload_retry_times: 3
gitee_files: |
release_assets/*
- name: Cleanup
if: always()
run: |
rm -rf release_assets/
- name: Summary
if: success()
run: |
echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!"
echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}"
-53
View File
@@ -1,53 +0,0 @@
name: Windows Release
permissions:
contents: write
on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+11 -1
View File
@@ -1,5 +1,6 @@
.idea
.vscode
.zed
upload
*.exe
*.db
@@ -9,6 +10,15 @@ logs
web/dist
.env
one-api
new-api
/__debug_bin*
.DS_Store
tiktoken_cache
.eslintcache
.eslintcache
.gocache
.cache
web/bun.lock
electron/node_modules
electron/dist
data/
+9 -7
View File
@@ -9,10 +9,12 @@ COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
FROM golang:alpine AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS
ARG TARGETARCH
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
@@ -21,15 +23,15 @@ RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/dist
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-api
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM alpine
RUN apk upgrade --no-cache \
&& apk add --no-cache ca-certificates tzdata ffmpeg \
&& apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates
COPY --from=builder2 /build/one-api /
COPY --from=builder2 /build/new-api /
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/one-api"]
ENTRYPOINT ["/new-api"]
+403 -167
View File
@@ -1,15 +1,17 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong>
</p>
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 Next-Generation Large Model Gateway and AI Asset Management System
🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="./README.md">中文</a> |
<strong>English</strong> |
<a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -28,6 +30,21 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-quick-start">Quick Start</a> •
<a href="#-key-features">Key Features</a> •
<a href="#-deployment">Deployment</a> •
<a href="#-documentation">Documentation</a> •
<a href="#-help-support">Help</a>
</p>
</div>
## 📝 Project Description
@@ -36,177 +53,396 @@
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> - This project is for personal learning purposes only, with no guarantee of stability or technical support.
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes.
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
## 📚 Documentation
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
You can also access the AI-generated DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Key Features
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
1. 🎨 Brand new UI interface
2. 🌍 Multi-language support
3. 💰 Online recharge functionality (YiPay)
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible with the original One API database
6. 💵 Support for pay-per-use model pricing
7. ⚖️ Support for weighted random channel selection
8. 📈 Data dashboard (console)
9. 🔒 Token grouping and model restrictions
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
14. Support for entering chat interface via /chat2link route
15. 🧠 Support for setting reasoning effort through model name suffixes:
1. OpenAI o-series models
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
2. Claude thinking models
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 Thinking-to-content functionality
17. 🔄 Model rate limiting for users
18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
3. Supported channels:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
## Model Support
This version supports multiple models, please refer to [API Documentation-Relay Interface](https://docs.newapi.pro/api) for details:
1. Third-party models **gpts** (gpt-4-gizmo-*)
2. Third-party channel [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [API Documentation](https://docs.newapi.pro/api/midjourney-proxy-image)
3. Third-party channel [Suno API](https://github.com/Suno-API/Suno-API) interface, [API Documentation](https://docs.newapi.pro/api/suno-music)
4. Custom channels, supporting full call address input
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
7. Dify, currently only supports chatflow
## Environment Variable Configuration
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false`
## Deployment
For detailed deployment guides, please refer to [Installation Guide-Deployment Methods](https://docs.newapi.pro/installation):
> [!TIP]
> Latest Docker image: `calciumion/new-api:latest`
### Multi-machine Deployment Considerations
- Environment variable `SESSION_SECRET` must be set, otherwise login status will be inconsistent across multiple machines
- If sharing Redis, `CRYPTO_SECRET` must be set, otherwise Redis content cannot be accessed across multiple machines
### Deployment Requirements
- Local database (default): SQLite (Docker deployment must mount the `/data` directory)
- Remote database: MySQL version >= 5.7.8, PgSQL version >= 9.6
### Deployment Methods
#### Using BaoTa Panel Docker Feature
Install BaoTa Panel (version **9.2.0** or above), find **New-API** in the application store and install it.
[Tutorial with images](./docs/BT.md)
#### Using Docker Compose (Recommended)
```shell
# Download the project
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Edit docker-compose.yml as needed
# Start
docker-compose up -d
```
#### Using Docker Image Directly
```shell
# Using SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## Channel Retry and Cache
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
### Cache Configuration Method
1. `REDIS_CONN_STRING`: Set Redis as cache
2. `MEMORY_CACHE_ENABLED`: Enable memory cache (no need to set manually if Redis is set)
## API Documentation
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
- [Chat API](https://docs.newapi.pro/api/openai-chat)
- [Image API](https://docs.newapi.pro/api/openai-image)
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat)
## Related Projects
- [One API](https://github.com/songquanpeng/one-api): Original project
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
Other projects based on New API:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
## Help and Support
If you have any questions, please refer to [Help and Support](https://docs.newapi.pro/support):
- [Community Interaction](https://docs.newapi.pro/support/community-interaction)
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
---
## 🤝 Trusted Partners
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="Peking University" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud" height="58"
/></a>
<em>No particular order</em>
</p>
<p align="center"><em>No particular order</em></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 Special Thanks
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>Thanks to <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> for providing free open-source development license for this project</strong>
</p>
---
## 🚀 Quick Start
### Using Docker Compose (Recommended)
```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Edit docker-compose.yml configuration
nano docker-compose.yml
# Start the service
docker-compose up -d
```
<details>
<summary><strong>Using Docker Commands</strong></summary>
```bash
# Pull the latest image
docker pull calciumion/new-api:latest
# Using SQLite (default)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
</details>
---
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation)
---
## 📚 Documentation
<div align="center">
### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**Quick Navigation:**
| Category | Link |
|------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
---
## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction)
### 🎨 Core Functions
| Feature | Description |
|------|------|
| 🎨 New UI | Modern user interface design |
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
| 🔄 Data Compatibility | Fully compatible with the original One API database |
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
### 💰 Payment and Billing
- ✅ Online recharge (EPay, Stripe)
- ✅ Pay-per-use model pricing
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
- ✅ Flexible billing policy configuration
### 🔐 Authorization and Security
- 😈 Discord authorization login
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
### 🚀 Advanced Features
**API Format Support:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
**Intelligent Routing:**
- ⚖️ Channel weighted random
- 🔄 Automatic retry on failure
- 🚦 User-level model rate limiting
**Format Conversion:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 Thinking-to-content functionality
**Reasoning Effort Support:**
<details>
<summary>View detailed configuration</summary>
**OpenAI series models:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude thinking models:**
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
**Google Gemini series models:**
- `gemini-2.5-flash-thinking` - Enable thinking mode
- `gemini-2.5-flash-nothinking` - Disable thinking mode
- `gemini-2.5-pro-thinking` - Enable thinking mode
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
</details>
---
## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api)
| Model Type | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - |
### 📡 Supported Interfaces
<details>
<summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image)
- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 Deployment
> [!TIP]
> **Latest Docker image:** `calciumion/new-api:latest`
### 📋 Deployment Requirements
| Component | Requirement |
|------|------|
| **Local database** | SQLite (Docker must mount `/data` directory)|
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
| **Container engine** | Docker / Docker Compose |
### ⚙️ Environment Variable Configuration
<details>
<summary>Common environment variable configuration</summary>
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `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` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 Deployment Methods
<details>
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Edit configuration
nano docker-compose.yml
# Start service
docker-compose up -d
```
</details>
<details>
<summary><strong>Method 2: Docker Commands</strong></summary>
**Using SQLite:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
**Using MySQL:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Path explanation:**
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
</details>
<details>
<summary><strong>Method 3: BaoTa Panel</strong></summary>
1. Install BaoTa Panel (≥ 9.2.0 version)
2. Search for **New-API** in the application store
3. One-click installation
📖 [Tutorial with images](./docs/BT.md)
</details>
### ⚠️ Multi-machine Deployment Considerations
> [!WARNING]
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
### 🔄 Channel Retry and Cache
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
**Cache configuration:**
- `REDIS_CONN_STRING`: Redis cache (recommended)
- `MEMORY_CACHE_ENABLED`: Memory cache
---
## 🔗 Related Projects
### Upstream Projects
| Project | Description |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | Original project base |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
### Supporting Tools
| Project | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
---
## 💬 Help Support
### 📖 Documentation Resources
| Resource | Link |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) |
### 🤝 Contribution Guide
Welcome all forms of contribution!
- 🐛 Report Bugs
- 💡 Propose New Features
- 📝 Improve Documentation
- 🔧 Submit Code
---
## 🌟 Star History
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 Thank you for using New API
If this project is helpful to you, welcome to give us a ⭐️ Star
**[Official Documentation](https://docs.newapi.pro/)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>
</div>
+442
View File
@@ -0,0 +1,442 @@
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA**
<p align="center">
<a href="./README.md">中文</a> |
<a href="./README.en.md">English</a> |
<strong>Français</strong> |
<a href="./README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-démarrage-rapide">Démarrage rapide</a> •
<a href="#-fonctionnalités-clés">Fonctionnalités clés</a> •
<a href="#-déploiement">Déploiement</a> •
<a href="#-documentation">Documentation</a> •
<a href="#-aide-support">Aide</a>
</p>
</div>
## 📝 Description du projet
> [!NOTE]
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
---
## 🤝 Partenaires de confiance
<p align="center">
<em>Sans ordre particulier</em>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 Remerciements spéciaux
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>Merci à <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> pour avoir fourni une licence de développement open-source gratuite pour ce projet</strong>
</p>
---
## 🚀 Démarrage rapide
### Utilisation de Docker Compose (recommandé)
```bash
# Cloner le projet
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Modifier la configuration docker-compose.yml
nano docker-compose.yml
# Démarrer le service
docker-compose up -d
```
<details>
<summary><strong>Utilisation des commandes Docker</strong></summary>
```bash
# Tirer la dernière image
docker pull calciumion/new-api:latest
# Utilisation de SQLite (par défaut)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Utilisation de MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data`
</details>
---
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation)
---
## 📚 Documentation
<div align="center">
### 📖 [Documentation officielle](https://docs.newapi.pro/) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**Navigation rapide:**
| Catégorie | Lien |
|------|------|
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) |
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) |
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
---
## ✨ Fonctionnalités clés
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) |
### 🎨 Fonctions principales
| Fonctionnalité | Description |
|------|------|
| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
| 🌍 Multilingue | Prend en charge le chinois, l'anglais, le français, le japonais |
| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
### 💰 Paiement et facturation
- ✅ Recharge en ligne (EPay, Stripe)
- ✅ Tarification des modèles de paiement à l'utilisation
- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
- ✅ Configuration flexible des politiques de facturation
### 🔐 Autorisation et sécurité
- 🤖 Connexion par autorisation LinuxDO
- 📱 Connexion par autorisation Telegram
- 🔑 Authentification unifiée OIDC
### 🚀 Fonctionnalités avancées
**Prise en charge des formats d'API:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
**Routage intelligent:**
- ⚖️ Sélection aléatoire pondérée des canaux
- 🔄 Nouvelle tentative automatique en cas d'échec
- 🚦 Limitation du débit du modèle pour les utilisateurs
**Conversion de format:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 Fonctionnalité de la pensée au contenu
**Prise en charge de l'effort de raisonnement:**
<details>
<summary>Voir la configuration détaillée</summary>
**Modèles de la série o d'OpenAI:**
- `o3-mini-high` - Effort de raisonnement élevé
- `o3-mini-medium` - Effort de raisonnement moyen
- `o3-mini-low` - Effort de raisonnement faible
**Modèles de pensée de Claude:**
- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
**Modèles de la série Google Gemini:**
- `gemini-2.5-flash-thinking` - Activer le mode de pensée
- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
- `gemini-2.5-pro-thinking` - Activer le mode de pensée
- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
</details>
---
## 🤖 Prise en charge des modèles
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api)
| Type de modèle | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | Mode ChatFlow | - |
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
### 📡 Interfaces prises en charge
<details>
<summary>Voir la liste complète des interfaces</summary>
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image)
- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 Déploiement
> [!TIP]
> **Dernière image Docker:** `calciumion/new-api:latest`
### 📋 Exigences de déploiement
| Composant | Exigence |
|------|------|
| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
| **Moteur de conteneur** | Docker / Docker Compose |
### ⚙️ Configuration des variables d'environnement
<details>
<summary>Configuration courante des variables d'environnement</summary>
| Nom de variable | Description | Valeur par défaut |
|--------|------|--------|
| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) |
| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - |
| `SQL_DSN` | Chaine de connexion à la base de données | - |
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
| `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` |
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 Méthodes de déploiement
<details>
<summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
```bash
# Cloner le projet
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Modifier la configuration
nano docker-compose.yml
# Démarrer le service
docker-compose up -d
```
</details>
<details>
<summary><strong>Méthode 2: Commandes Docker</strong></summary>
**Utilisation de SQLite:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
**Utilisation de MySQL:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Explication du chemin:**
> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel
> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data`
</details>
<details>
<summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
1. Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
2. Recherchez **New-API** dans le magasin d'applications et installez-le.
📖 [Tutoriel avec des images](./docs/BT.md)
</details>
### ⚠️ Considérations sur le déploiement multi-machines
> [!WARNING]
> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines
> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées
### 🔄 Nouvelle tentative de canal et cache
**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
**Configuration du cache:**
- `REDIS_CONN_STRING`: Cache Redis (recommandé)
- `MEMORY_CACHE_ENABLED`: Cache mémoire
---
## 🔗 Projets connexes
### Projets en amont
| Projet | Description |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | Base du projet original |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Prise en charge de l'interface Midjourney |
### Outils d'accompagnement
| Projet | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
---
## 💬 Aide et support
### 📖 Ressources de documentation
| Ressource | Lien |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) |
### 🤝 Guide de contribution
Bienvenue à toutes les formes de contribution!
- 🐛 Signaler des bogues
- 💡 Proposer de nouvelles fonctionnalités
- 📝 Améliorer la documentation
- 🔧 Soumettre du code
---
## 🌟 Historique des étoiles
<div align="center">
[![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 Merci d'utiliser New API
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile
**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Construit avec ❤️ par QuantumNous</sub>
</div>
+451
View File
@@ -0,0 +1,451 @@
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **次世代大規模モデルゲートウェイとAI資産管理システム**
<p align="center">
<a href="./README.md">中文</a> |
<a href="./README.en.md">English</a> |
<a href="./README.fr.md">Français</a> |
<strong>日本語</strong>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
</a>
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
</a>
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
</a>
<a href="https://hub.docker.com/r/CalciumIon/new-api">
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
</a>
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-クイックスタート">クイックスタート</a> •
<a href="#-主な機能">主な機能</a> •
<a href="#-デプロイ">デプロイ</a> •
<a href="#-ドキュメント">ドキュメント</a> •
<a href="#-ヘルプサポート">ヘルプ</a>
</p>
</div>
## 📝 プロジェクト説明
> [!NOTE]
> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです
> [!IMPORTANT]
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
---
## 🤝 信頼できるパートナー
<p align="center">
<em>順不同</em>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 特別な感謝
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>感謝 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> が本プロジェクトに無料のオープンソース開発ライセンスを提供してくれたことに感謝します</strong>
</p>
---
## 🚀 クイックスタート
### Docker Composeを使用(推奨)
```bash
# プロジェクトをクローン
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# docker-compose.yml 設定を編集
nano docker-compose.yml
# サービスを起動
docker-compose up -d
```
<details>
<summary><strong>Dockerコマンドを使用</strong></summary>
```bash
# 最新のイメージをプル
docker pull calciumion/new-api:latest
# SQLiteを使用(デフォルト)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# MySQLを使用
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data`
</details>
---
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。
---
## 📚 ドキュメント
<div align="center">
### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**クイックナビゲーション:**
| カテゴリ | リンク |
|------|------|
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) |
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) |
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) |
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
---
## ✨ 主な機能
> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください。
### 🎨 コア機能
| 機能 | 説明 |
|------|------|
| 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
| 🌍 多言語 | 中国語、英語、フランス語、日本語をサポート |
| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
### 💰 支払いと課金
- ✅ オンライン充電(EPay、Stripe
- ✅ モデルの従量課金
- ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
- ✅ 柔軟な課金ポリシー設定
### 🔐 認証とセキュリティ
- 🤖 LinuxDO認証ログイン
- 📱 Telegram認証ログイン
- 🔑 OIDC統一認証
### 🚀 高度な機能
**APIフォーマットサポート:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)Azureを含む)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
**インテリジェントルーティング:**
- ⚖️ チャネル重み付けランダム
- 🔄 失敗自動リトライ
- 🚦 ユーザーレベルモデルレート制限
**フォーマット変換:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 思考からコンテンツへの機能
**Reasoning Effort サポート:**
<details>
<summary>詳細設定を表示</summary>
**OpenAIシリーズモデル:**
- `o3-mini-high` - 高思考努力
- `o3-mini-medium` - 中思考努力
- `o3-mini-low` - 低思考努力
- `gpt-5-high` - 高思考努力
- `gpt-5-medium` - 中思考努力
- `gpt-5-low` - 低思考努力
**Claude思考モデル:**
- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
**Google Geminiシリーズモデル:**
- `gemini-2.5-flash-thinking` - 思考モードを有効にする
- `gemini-2.5-flash-nothinking` - 思考モードを無効にする
- `gemini-2.5-pro-thinking` - 思考モードを有効にする
- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
</details>
---
## 🤖 モデルサポート
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api)
| モデルタイプ | 説明 | ドキュメント |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlowモード | - |
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
### 📡 サポートされているインターフェース
<details>
<summary>完全なインターフェースリストを表示</summary>
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses)
- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio)
- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat)
- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 デプロイ
> [!TIP]
> **最新のDockerイメージ:** `calciumion/new-api:latest`
### 📋 デプロイ要件
| コンポーネント | 要件 |
|------|------|
| **ローカルデータベース** | SQLiteDockerは `/data` ディレクトリをマウントする必要があります)|
| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
| **コンテナエンジン** | Docker / Docker Compose |
### ⚙️ 環境変数設定
<details>
<summary>一般的な環境変数設定</summary>
| 変数名 | 説明 | デフォルト値 |
|--------|------|--------|
| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - |
| `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - |
| `SQL_DSN** | データベース接続文字列 | - |
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
| `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` |
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 デプロイ方法
<details>
<summary><strong>方法 1: Docker Compose(推奨)</strong></summary>
```bash
# プロジェクトをクローン
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# 設定を編集
nano docker-compose.yml
# サービスを起動
docker-compose up -d
```
</details>
<details>
<summary><strong>方法 2: Dockerコマンド</strong></summary>
**SQLiteを使用:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
**MySQLを使用:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 パス説明:**
> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます
> - 絶対パスを使用することもできます:`/your/custom/path:/data`
</details>
<details>
<summary><strong>方法 3: 宝塔パネル</strong></summary>
1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。
📖 [画像付きチュートリアル](./docs/BT.md)
</details>
### ⚠️ マルチマシンデプロイの注意事項
> [!WARNING]
> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません
### 🔄 チャネルリトライとキャッシュ
**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
**キャッシュ設定:**
- `REDIS_CONN_STRING`Redisキャッシュ(推奨)
- `MEMORY_CACHE_ENABLED`:メモリキャッシュ
---
## 🔗 関連プロジェクト
### 上流プロジェクト
| プロジェクト | 説明 |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | オリジナルプロジェクトベース |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourneyインターフェースサポート |
### 補助ツール
| プロジェクト | 説明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
---
## 💬 ヘルプサポート
### 📖 ドキュメントリソース
| リソース | リンク |
|------|------|
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) |
### 🤝 貢献ガイド
あらゆる形の貢献を歓迎します!
- 🐛 バグを報告する
- 💡 新しい機能を提案する
- 📝 ドキュメントを改善する
- 🔧 コードを提出する
---
## 🌟 スター履歴
<div align="center">
[![スター履歴チャート](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 New APIをご利用いただきありがとうございます
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
<sub>❤️ で構築された QuantumNous</sub>
</div>
+406 -168
View File
@@ -1,15 +1,17 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
</p>
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥新一代大模型网关与AI资产管理系统
🍥 **新一代大模型网关与AI资产管理系统**
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<strong>中文</strong> |
<a href="./README.en.md">English</a> |
<a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a>
</p>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -28,184 +30,420 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-快速开始">快速开始</a> •
<a href="#-主要特性">主要特性</a> •
<a href="#-部署">部署</a> •
<a href="#-文档">文档</a> •
<a href="#-帮助支持">帮助</a>
</p>
</div>
## 📝 项目说明
> [!NOTE]
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发
> 本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api) 的基础上进行二次开发
> [!IMPORTANT]
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
## 📚 文档
详细文档请访问我们的官方Wiki[https://docs.newapi.pro/](https://docs.newapi.pro/)
也可访问AI生成的DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ 主要特性
New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction)
1. 🎨 全新的UI界面
2. 🌍 多语言支持
3. 💰 支持在线充值功能(易支付)
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
5. 🔄 兼容原版One API的数据库
6. 💵 支持模型按次数收费
7. ⚖️ 支持渠道加权随机
8. 📈 数据看板(控制台)
9. 🔒 令牌分组、模型限制
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
14. 支持使用路由/chat2link进入聊天界面
15. 🧠 支持通过模型名称后缀设置 reasoning effort
1. OpenAI o系列模型
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
2. Claude 思考模型
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 思考转内容功能
17. 🔄 针对用户的模型限流功能
18. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1.`系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
## 模型支持
此版本支持多种模型,详情请参考[接口文档-中继接口](https://docs.newapi.pro/api)
1. 第三方模型 **gpts** gpt-4-gizmo-*
2. 第三方渠道[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[接口文档](https://docs.newapi.pro/api/midjourney-proxy-image)
3. 第三方渠道[Suno API](https://github.com/Suno-API/Suno-API)接口,[接口文档](https://docs.newapi.pro/api/suno-music)
4. 自定义渠道,支持填入完整调用地址
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
7. Dify,当前仅支持chatflow
## 环境变量配置
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
- `COHERE_SAFETY_SETTING`Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE`
- `GEMINI_VISION_MAX_IMAGE_NUM`Gemini模型最大图片数量,默认 `16`
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容
- `AZURE_DEFAULT_API_VERSION`Azure渠道默认API版本,默认 `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
## 部署
详细部署指南请参考[安装指南-部署方式](https://docs.newapi.pro/installation)
> [!TIP]
> 最新版Docker镜像:`calciumion/new-api:latest`
### 多机部署注意事项
- 必须设置环境变量 `SESSION_SECRET`,否则会导致多机部署时登录状态不一致
- 如果公用Redis,必须设置 `CRYPTO_SECRET`,否则会导致多机部署时Redis内容无法获取
### 部署要求
- 本地数据库(默认):SQLite(Docker部署必须挂载`/data`目录)
- 远程数据库:MySQL版本 >= 5.7.8PgSQL版本 >= 9.6
### 部署方式
#### 使用宝塔面板Docker功能部署
安装宝塔面板(**9.2.0版本**及以上),在应用商店中找到**New-API**安装即可。
[图文教程](./docs/BT.md)
#### 使用Docker Compose部署(推荐)
```shell
# 下载项目
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# 按需编辑docker-compose.yml
# 启动
docker-compose up -d
```
#### 直接使用Docker镜像
```shell
# 使用SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
# 使用MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## 渠道重试与缓存
渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。
### 缓存设置方法
1. `REDIS_CONN_STRING`:设置Redis作为缓存
2. `MEMORY_CACHE_ENABLED`:启用内存缓存(设置了Redis则无需手动设置)
## 接口文档
详细接口文档请参考[接口文档](https://docs.newapi.pro/api)
- [聊天接口(Chat](https://docs.newapi.pro/api/openai-chat)
- [图像接口(Image](https://docs.newapi.pro/api/openai-image)
- [重排序接口(Rerank](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话接口(Realtime](https://docs.newapi.pro/api/openai-realtime)
- [Claude聊天接口(messages](https://docs.newapi.pro/api/anthropic-chat)
## 相关项目
- [One API](https://github.com/songquanpeng/one-api):原版项目
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)Midjourney接口支持
- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
其他基于New API的项目:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能优化版
## 帮助支持
如有问题,请参考[帮助支持](https://docs.newapi.pro/support)
- [社区交流](https://docs.newapi.pro/support/community-interaction)
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
- [常见问题](https://docs.newapi.pro/support/faq)
---
## 🤝 我们信任的合作伙伴
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank"><img
src="./docs/images/cherry-studio.svg" alt="Cherry Studio" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://bda.pku.edu.cn/" target="_blank"><img
src="./docs/images/pku.png" alt="北京大学" height="58"
/></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank"><img
src="./docs/images/ucloud.svg" alt="UCloud 优刻得" height="58"
/></a>
<em>排名不分先后</em>
</p>
<p align="center"><em>排名不分先后</em></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里云" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 特别鸣谢
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>感谢 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> 为本项目提供免费的开源开发许可证</strong>
</p>
---
## 🚀 快速开始
### 使用 Docker Compose(推荐)
```bash
# 克隆项目
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# 编辑 docker-compose.yml 配置
nano docker-compose.yml
# 启动服务
docker-compose up -d
```
<details>
<summary><strong>使用 Docker 命令</strong></summary>
```bash
# 拉取最新镜像
docker pull calciumion/new-api:latest
# 使用 SQLite(默认)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# 使用 MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data`
</details>
---
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation)
---
## 📚 文档
<div align="center">
### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**快速导航:**
| 分类 | 链接 |
|------|------|
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
---
## ✨ 主要特性
> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction)
### 🎨 核心功能
| 特性 | 说明 |
|------|------|
| 🎨 全新 UI | 现代化的用户界面设计 |
| 🌍 多语言 | 支持中文、英文、法语、日语 |
| 🔄 数据兼容 | 完全兼容原版 One API 数据库 |
| 📈 数据看板 | 可视化控制台与统计分析 |
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
### 💰 支付与计费
- ✅ 在线充值(易支付、Stripe)
- ✅ 模型按次数收费
- ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型)
- ✅ 灵活的计费策略配置
### 🔐 授权与安全
- 😈 Discord 授权登录
- 🤖 LinuxDO 授权登录
- 📱 Telegram 授权登录
- 🔑 OIDC 统一认证
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
### 🚀 高级功能
**API 格式支持:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
**智能路由:**
- ⚖️ 渠道加权随机
- 🔄 失败自动重试
- 🚦 用户级别模型限流
**格式转换:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 思考转内容功能
**Reasoning Effort 支持:**
<details>
<summary>查看详细配置</summary>
**OpenAI 系列模型:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude 思考模型:**
- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式
**Google Gemini 系列模型:**
- `gemini-2.5-flash-thinking` - 启用思考模式
- `gemini-2.5-flash-nothinking` - 禁用思考模式
- `gemini-2.5-pro-thinking` - 启用思考模式
- `gemini-2.5-pro-thinking-128` - 启用思考模式,并设置思考预算为128tokens
</details>
---
## 🤖 模型支持
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api)
| 模型类型 | 说明 | 文档 |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义 | 支持完整调用地址 | - |
### 📡 支持的接口
<details>
<summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image)
- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio)
- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat)
</details>
---
## 🚢 部署
> [!TIP]
> **最新版 Docker 镜像:** `calciumion/new-api:latest`
### 📋 部署要求
| 组件 | 要求 |
|------|------|
| **本地数据库** | SQLiteDocker 需挂载 `/data` 目录)|
| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| **容器引擎** | Docker / Docker Compose |
### ⚙️ 环境变量配置
<details>
<summary>常用环境变量配置</summary>
| 变量名 | 说明 | 默认值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 会话密钥(多机部署必须) | - |
| `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - |
| `SQL_DSN` | 数据库连接字符串 | - |
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
| `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)
</details>
### 🔧 部署方式
<details>
<summary><strong>方式 1Docker Compose(推荐)</strong></summary>
```bash
# 克隆项目
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# 编辑配置
nano docker-compose.yml
# 启动服务
docker-compose up -d
```
</details>
<details>
<summary><strong>方式 2Docker 命令</strong></summary>
**使用 SQLite**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
**使用 MySQL**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 路径说明:**
> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹
> - 也可使用绝对路径,如:`/your/custom/path:/data`
</details>
<details>
<summary><strong>方式 3:宝塔面板</strong></summary>
1. 安装宝塔面板(≥ 9.2.0 版本)
2. 在应用商店搜索 **New-API**
3. 一键安装
📖 [图文教程](./docs/BT.md)
</details>
### ⚠️ 多机部署注意事项
> [!WARNING]
> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致
> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密
### 🔄 渠道重试与缓存
**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数`
**缓存配置:**
- `REDIS_CONN_STRING`Redis 缓存(推荐)
- `MEMORY_CACHE_ENABLED`:内存缓存
---
## 🔗 相关项目
### 上游项目
| 项目 | 说明 |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | 原版项目基础 |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney 接口支持 |
### 配套工具
| 项目 | 说明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
---
## 💬 帮助支持
### 📖 文档资源
| 资源 | 链接 |
|------|------|
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) |
### 🤝 贡献指南
欢迎各种形式的贡献!
- 🐛 报告 Bug
- 💡 提出新功能
- 📝 改进文档
- 🔧 提交代码
---
## 🌟 Star History
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 感谢使用 New API
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star!
**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>
</div>
+9 -1
View File
@@ -1,6 +1,6 @@
package common
import "one-api/constant"
import "github.com/QuantumNous/new-api/constant"
func ChannelType2APIType(channelType int) (int, bool) {
apiType := -1
@@ -65,6 +65,14 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeCoze
case constant.ChannelTypeJimeng:
apiType = constant.APITypeJimeng
case constant.ChannelTypeMoonshot:
apiType = constant.APITypeMoonshot
case constant.ChannelTypeSubmodel:
apiType = constant.APITypeSubmodel
case constant.ChannelTypeMiniMax:
apiType = constant.APITypeMiniMax
case constant.ChannelTypeReplicate:
apiType = constant.APITypeReplicate
}
if apiType == -1 {
return constant.APITypeOpenAI, false
+296
View File
@@ -0,0 +1,296 @@
package common
import (
"context"
"encoding/binary"
"fmt"
"io"
"github.com/abema/go-mp4"
"github.com/go-audio/aiff"
"github.com/go-audio/wav"
"github.com/jfreymuth/oggvorbis"
"github.com/mewkiz/flac"
"github.com/pkg/errors"
"github.com/tcolgate/mp3"
"github.com/yapingcat/gomedia/go-codec"
)
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
// 根据文件扩展名选择解析器
switch ext {
case ".mp3":
duration, err = getMP3Duration(f)
case ".wav":
duration, err = getWAVDuration(f)
case ".flac":
duration, err = getFLACDuration(f)
case ".m4a", ".mp4":
duration, err = getM4ADuration(f)
case ".ogg", ".oga", ".opus":
duration, err = getOGGDuration(f)
if err != nil {
duration, err = getOpusDuration(f)
}
case ".aiff", ".aif", ".aifc":
duration, err = getAIFFDuration(f)
case ".webm":
duration, err = getWebMDuration(f)
case ".aac":
duration, err = getAACDuration(f)
default:
return 0, fmt.Errorf("unsupported audio format: %s", ext)
}
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
return duration, err
}
// getMP3Duration 解析 MP3 文件以获取时长。
// 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。
// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
func getMP3Duration(r io.Reader) (float64, error) {
d := mp3.NewDecoder(r)
var f mp3.Frame
skipped := 0
duration := 0.0
for {
if err := d.Decode(&f, &skipped); err != nil {
if err == io.EOF {
break
}
return 0, errors.Wrap(err, "failed to decode mp3 frame")
}
duration += f.Duration().Seconds()
}
return duration, nil
}
// getWAVDuration 解析 WAV 文件头以获取时长。
func getWAVDuration(r io.ReadSeeker) (float64, error) {
dec := wav.NewDecoder(r)
if !dec.IsValidFile() {
return 0, errors.New("invalid wav file")
}
d, err := dec.Duration()
if err != nil {
return 0, errors.Wrap(err, "failed to get wav duration")
}
return d.Seconds(), nil
}
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
func getFLACDuration(r io.Reader) (float64, error) {
stream, err := flac.Parse(r)
if err != nil {
return 0, errors.Wrap(err, "failed to parse flac stream")
}
defer stream.Close()
// 时长 = 总采样数 / 采样率
duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
return duration, nil
}
// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
func getM4ADuration(r io.ReadSeeker) (float64, error) {
// go-mp4 库需要 ReadSeeker 接口
info, err := mp4.Probe(r)
if err != nil {
return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
}
// 时长 = Duration / Timescale
return float64(info.Duration) / float64(info.Timescale), nil
}
// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
func getOGGDuration(r io.ReadSeeker) (float64, error) {
// 重置 reader 到开头
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek ogg file")
}
reader, err := oggvorbis.NewReader(r)
if err != nil {
return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
}
// 计算时长 = 总采样数 / 采样率
// 需要读取整个文件来获取总采样数
channels := reader.Channels()
sampleRate := reader.SampleRate()
// 估算方法:读取到文件结尾
var totalSamples int64
buf := make([]float32, 4096*channels)
for {
n, err := reader.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return 0, errors.Wrap(err, "failed to read ogg samples")
}
totalSamples += int64(n / channels)
}
duration := float64(totalSamples) / float64(sampleRate)
return duration, nil
}
// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
func getOpusDuration(r io.ReadSeeker) (float64, error) {
// Opus 通常封装在 OGG 容器中
// 我们需要解析 OGG 页面来获取时长信息
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek opus file")
}
// 读取 OGG 页面头部
var totalGranulePos int64
buf := make([]byte, 27) // OGG 页面头部最小大小
for {
n, err := r.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return 0, errors.Wrap(err, "failed to read opus/ogg page")
}
if n < 27 {
break
}
// 检查 OGG 页面标识 "OggS"
if string(buf[0:4]) != "OggS" {
// 跳过一些字节继续寻找
if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
break
}
continue
}
// 读取 granule position (字节 6-13, 小端序)
granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
if granulePos > totalGranulePos {
totalGranulePos = granulePos
}
// 读取段表大小
numSegments := int(buf[26])
segmentTable := make([]byte, numSegments)
if _, err := io.ReadFull(r, segmentTable); err != nil {
break
}
// 计算页面数据大小并跳过
var pageSize int
for _, segSize := range segmentTable {
pageSize += int(segSize)
}
if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
break
}
}
// Opus 的采样率固定为 48000 Hz
duration := float64(totalGranulePos) / 48000.0
return duration, nil
}
// getAIFFDuration 解析 AIFF 文件头以获取时长。
func getAIFFDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek aiff file")
}
dec := aiff.NewDecoder(r)
if !dec.IsValidFile() {
return 0, errors.New("invalid aiff file")
}
d, err := dec.Duration()
if err != nil {
return 0, errors.Wrap(err, "failed to get aiff duration")
}
return d.Seconds(), nil
}
// getWebMDuration 解析 WebM 文件以获取时长。
// WebM 使用 Matroska 容器格式
func getWebMDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek webm file")
}
// WebM/Matroska 文件的解析比较复杂
// 这里提供一个简化的实现,读取 EBML 头部
// 对于完整的 WebM 解析,可能需要使用专门的库
// 简单实现:查找 Duration 元素
// WebM Duration 的 Element ID 是 0x4489
// 这是一个简化版本,可能不适用于所有 WebM 文件
buf := make([]byte, 8192)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return 0, errors.Wrap(err, "failed to read webm file")
}
// 尝试查找 Duration 元素(这是一个简化的方法)
// 实际的 WebM 解析需要完整的 EBML 解析器
// 这里返回错误,建议使用专门的库
if n > 0 {
// 检查 EBML 标识
if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
// 这是一个有效的 EBML 文件
// 但完整解析需要更复杂的逻辑
return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
}
}
return 0, errors.New("failed to parse webm file")
}
// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
// 使用 gomedia 库来解析 AAC ADTS 帧
func getAACDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek aac file")
}
// 读取整个文件内容
data, err := io.ReadAll(r)
if err != nil {
return 0, errors.Wrap(err, "failed to read aac file")
}
var totalFrames int64
var sampleRate int
// 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
codec.SplitAACFrame(data, func(aac []byte) {
// 解析 ADTS 头部以获取采样率信息
if len(aac) >= 7 {
// 使用 ConvertADTSToASC 来获取音频配置信息
asc, err := codec.ConvertADTSToASC(aac)
if err == nil && sampleRate == 0 {
sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
}
totalFrames++
}
})
if sampleRate == 0 || totalFrames == 0 {
return 0, errors.New("no valid aac frames found")
}
// 每个 AAC ADTS 帧包含 1024 个采样
totalSamples := totalFrames * 1024
duration := float64(totalSamples) / float64(sampleRate)
return duration, nil
}
+6 -3
View File
@@ -19,6 +19,7 @@ var TopUpLink = ""
// var ChatLink = ""
// var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
var DisplayInCurrencyEnabled = true
var DisplayTokenStatEnabled = true
var DrawingEnabled = true
@@ -83,6 +84,7 @@ var GitHubClientId = ""
var GitHubClientSecret = ""
var LinuxDOClientId = ""
var LinuxDOClientSecret = ""
var LinuxDOMinimumTrustLevel = 0
var WeChatServerAddress = ""
var WeChatServerToken = ""
@@ -157,14 +159,15 @@ var (
GlobalWebRateLimitNum int
GlobalWebRateLimitDuration int64
CriticalRateLimitEnable bool
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
UploadRateLimitNum = 10
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 10
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute
+19
View File
@@ -0,0 +1,19 @@
package common
import (
"fmt"
"github.com/jinzhu/copier"
)
func DeepCopy[T any](src *T) (*T, error) {
if src == nil {
return nil, fmt.Errorf("copy source cannot be nil")
}
var dst T
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
if err != nil {
return nil, err
}
return &dst, nil
}
+1
View File
@@ -4,6 +4,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
+1 -1
View File
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=5000"
var SQLitePath = "one-api.db?_busy_timeout=30000"
+3
View File
@@ -86,5 +86,8 @@ func SendEmail(subject string, receiver string, content string) error {
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
if err != nil {
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
}
return err
}
+14 -3
View File
@@ -2,9 +2,11 @@ package common
import (
"embed"
"github.com/gin-contrib/static"
"io/fs"
"net/http"
"os"
"github.com/gin-contrib/static"
)
// Credit: https://github.com/gin-contrib/static/issues/19
@@ -13,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
@@ -21,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),
}
}
+33
View File
@@ -0,0 +1,33 @@
package common
import "github.com/QuantumNous/new-api/constant"
// EndpointInfo 描述单个端点的默认请求信息
// path: 上游路径
// method: HTTP 请求方式,例如 POST/GET
// 目前均为 POST,后续可扩展
//
// json 标签用于直接序列化到 API 输出
// 例如:{"path":"/v1/chat/completions","method":"POST"}
type EndpointInfo struct {
Path string `json:"path"`
Method string `json:"method"`
}
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
}
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
info, ok := defaultEndpointInfoMap[et]
return info, ok
}
+3 -1
View File
@@ -1,6 +1,6 @@
package common
import "one-api/constant"
import "github.com/QuantumNous/new-api/constant"
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
@@ -26,6 +26,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
if IsOpenAIResponseOnlyModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
+126 -3
View File
@@ -2,12 +2,18 @@ package common
import (
"bytes"
"github.com/gin-gonic/gin"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"one-api/constant"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-gonic/gin"
)
const KeyRequestBody = "key_request_body"
@@ -31,9 +37,16 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
if err != nil {
return err
}
//if DebugEnabled {
// println("UnmarshalBodyReusable request body:", string(requestBody))
//}
contentType := c.Request.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "application/json") {
err = Unmarshal(requestBody, &v)
err = Unmarshal(requestBody, v)
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
err = parseFormData(requestBody, v)
} else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
err = parseMultipartFormData(c, requestBody, v)
} else {
// skip for now
// TODO: someday non json request have variant model, we will need to implementation this
@@ -109,3 +122,113 @@ func ApiSuccess(c *gin.Context, data any) {
"data": data,
})
}
func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
requestBody, err := GetRequestBody(c)
if err != nil {
return nil, err
}
contentType := c.Request.Header.Get("Content-Type")
boundary, err := parseBoundary(contentType)
if err != nil {
return nil, err
}
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
form, err := reader.ReadForm(multipartMemoryLimit())
if err != nil {
return nil, err
}
// Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return form, nil
}
func processFormMap(formMap map[string]any, v any) error {
jsonData, err := Marshal(formMap)
if err != nil {
return err
}
err = Unmarshal(jsonData, v)
if err != nil {
return err
}
return nil
}
func parseFormData(data []byte, v any) error {
values, err := url.ParseQuery(string(data))
if err != nil {
return err
}
formMap := make(map[string]any)
for key, vals := range values {
if len(vals) == 1 {
formMap[key] = vals[0]
} else {
formMap[key] = vals
}
}
return processFormMap(formMap, v)
}
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
contentType := c.Request.Header.Get("Content-Type")
boundary, err := parseBoundary(contentType)
if err != nil {
if errors.Is(err, errBoundaryNotFound) {
return Unmarshal(data, v) // Fallback to JSON
}
return err
}
reader := multipart.NewReader(bytes.NewReader(data), boundary)
form, err := reader.ReadForm(multipartMemoryLimit())
if err != nil {
return err
}
defer form.RemoveAll()
formMap := make(map[string]any)
for key, vals := range form.Value {
if len(vals) == 1 {
formMap[key] = vals[0]
} else {
formMap[key] = vals
}
}
return processFormMap(formMap, v)
}
var errBoundaryNotFound = errors.New("multipart boundary not found")
// parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType
func parseBoundary(contentType string) (string, error) {
if contentType == "" {
return "", errBoundaryNotFound
}
// Boundary-UUID / boundary-------xxxxxx
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return "", err
}
boundary, ok := params["boundary"]
if !ok || boundary == "" {
return "", errBoundaryNotFound
}
return boundary, nil
}
// multipartMemoryLimit returns the configured multipart memory limit in bytes
func multipartMemoryLimit() int64 {
limitMB := constant.MaxFileDownloadMB
if limitMB <= 0 {
limitMB = 32
}
return int64(limitMB) << 20
}
+2 -1
View File
@@ -3,8 +3,9 @@ package common
import (
"context"
"fmt"
"github.com/bytedance/gopkg/util/gopool"
"math"
"github.com/bytedance/gopkg/util/gopool"
)
var relayGoPool gopool.Pool
+32 -7
View File
@@ -4,11 +4,13 @@ import (
"flag"
"fmt"
"log"
"one-api/constant"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/constant"
)
var (
@@ -19,15 +21,20 @@ var (
)
func printHelp() {
fmt.Println("New API " + Version + " - All in one API service for OpenAI API.")
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
fmt.Println("Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]")
fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.")
fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api")
fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api")
fmt.Println("Usage: newapi [--port <port>] [--log-dir <log directory>] [--version] [--help]")
}
func InitEnv() {
flag.Parse()
envVersion := os.Getenv("VERSION")
if envVersion != "" {
Version = envVersion
}
if *PrintVersion {
fmt.Println(Version)
os.Exit(0)
@@ -97,17 +104,22 @@ func InitEnv() {
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
initConstantEnv()
}
func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
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)
@@ -117,4 +129,17 @@ func initConstantEnv() {
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
// 是否启用错误日志
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
if soraPatchStr != "" {
var taskPricePatches []string
soraPatches := strings.Split(soraPatchStr, ",")
for _, patch := range soraPatches {
trimmedPatch := strings.TrimSpace(patch)
if trimmedPatch != "" {
taskPricePatches = append(taskPricePatches, trimmedPatch)
}
}
constant.TaskPricePatches = taskPricePatches
}
}
+22
View File
@@ -0,0 +1,22 @@
package common
import "net"
func IsPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
private := []net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
}
for _, privateNet := range private {
if privateNet.Contains(ip) {
return true
}
}
return false
}
+24 -1
View File
@@ -3,6 +3,7 @@ package common
import (
"bytes"
"encoding/json"
"io"
)
func Unmarshal(data []byte, v any) error {
@@ -13,10 +14,32 @@ func UnmarshalJsonStr(data string, v any) error {
return json.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader *bytes.Reader, v any) error {
func DecodeJson(reader io.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
}
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func GetJsonType(data json.RawMessage) string {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return "unknown"
}
firstChar := bytes.TrimSpace(data)[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
}
+3 -2
View File
@@ -4,9 +4,10 @@ import (
"context"
_ "embed"
"fmt"
"github.com/go-redis/redis/v8"
"one-api/common"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
)
//go:embed lua/rate_limit.lua
-123
View File
@@ -1,123 +0,0 @@
package common
import (
"context"
"encoding/json"
"fmt"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
const (
loggerINFO = "INFO"
loggerWarn = "WARN"
loggerError = "ERR"
)
const maxLogCount = 1000000
var logCount int
var setupLogLock sync.Mutex
var setupLogWorking bool
func SetupLogger() {
if *LogDir != "" {
ok := setupLogLock.TryLock()
if !ok {
log.Println("setup log is already working")
return
}
defer func() {
setupLogLock.Unlock()
setupLogWorking = false
}()
logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatal("failed to open log file")
}
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
}
}
func SysLog(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
}
func SysError(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
}
func LogInfo(ctx context.Context, msg string) {
logHelper(ctx, loggerINFO, msg)
}
func LogWarn(ctx context.Context, msg string) {
logHelper(ctx, loggerWarn, msg)
}
func LogError(ctx context.Context, msg string) {
logHelper(ctx, loggerError, msg)
}
func logHelper(ctx context.Context, level string, msg string) {
writer := gin.DefaultErrorWriter
if level == loggerINFO {
writer = gin.DefaultWriter
}
id := ctx.Value(RequestIdKey)
if id == nil {
id = "SYSTEM"
}
now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
logCount++ // we don't need accurate count, so no lock here
if logCount > maxLogCount && !setupLogWorking {
logCount = 0
setupLogWorking = true
gopool.Go(func() {
SetupLogger()
})
}
}
func FatalLog(v ...any) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
os.Exit(1)
}
func LogQuota(quota int) string {
if DisplayInCurrencyEnabled {
return fmt.Sprintf("%.6f 额度", float64(quota)/QuotaPerUnit)
} else {
return fmt.Sprintf("%d 点额度", quota)
}
}
func FormatQuota(quota int) string {
if DisplayInCurrencyEnabled {
return fmt.Sprintf("%.6f", float64(quota)/QuotaPerUnit)
} else {
return fmt.Sprintf("%d", quota)
}
}
// LogJson 仅供测试使用 only for test
func LogJson(ctx context.Context, msg string, obj any) {
jsonStr, err := json.Marshal(obj)
if err != nil {
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
return
}
LogInfo(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
}
+2 -1
View File
@@ -2,10 +2,11 @@ package common
import (
"fmt"
"github.com/shirou/gopsutil/cpu"
"os"
"runtime/pprof"
"time"
"github.com/shirou/gopsutil/cpu"
)
// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
+5
View File
@@ -0,0 +1,5 @@
package common
func GetTrustQuota() int {
return int(10 * QuotaPerUnit)
}
+327
View File
@@ -0,0 +1,327 @@
package common
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
AllowPrivateIp bool
DomainFilterMode bool // true: 白名单, false: 黑名单
DomainList []string // domain format, e.g. example.com, *.example.com
IpFilterMode bool // true: 白名单, false: 黑名单
IpList []string // CIDR or single IP
AllowedPorts []int // 允许的端口范围
ApplyIPFilterForDomain bool // 对域名启用IP过滤
}
// DefaultSSRFProtection 默认SSRF防护配置
var DefaultSSRFProtection = &SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: true,
DomainList: []string{},
IpFilterMode: true,
IpList: []string{},
AllowedPorts: []int{},
}
// isPrivateIP 检查IP是否为私有地址
func isPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// 检查私有网段
private := []net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
}
for _, privateNet := range private {
if privateNet.Contains(ip) {
return true
}
}
// 检查IPv6私有地址
if ip.To4() == nil {
// IPv6 loopback
if ip.Equal(net.IPv6loopback) {
return true
}
// IPv6 link-local
if strings.HasPrefix(ip.String(), "fe80:") {
return true
}
// IPv6 unique local
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
return true
}
}
return false
}
// parsePortRanges 解析端口范围配置
// 支持格式: "80", "443", "8000-9000"
func parsePortRanges(portConfigs []string) ([]int, error) {
var ports []int
for _, config := range portConfigs {
config = strings.TrimSpace(config)
if config == "" {
continue
}
if strings.Contains(config, "-") {
// 处理端口范围 "8000-9000"
parts := strings.Split(config, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid port range format: %s", config)
}
startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
}
endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
}
if startPort > endPort {
return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
}
if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
}
// 添加范围内的所有端口
for port := startPort; port <= endPort; port++ {
ports = append(ports, port)
}
} else {
// 处理单个端口 "80"
port, err := strconv.Atoi(config)
if err != nil {
return nil, fmt.Errorf("invalid port number: %s", config)
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
}
ports = append(ports, port)
}
}
return ports, nil
}
// isAllowedPort 检查端口是否被允许
func (p *SSRFProtection) isAllowedPort(port int) bool {
if len(p.AllowedPorts) == 0 {
return true // 如果没有配置端口限制,则允许所有端口
}
for _, allowedPort := range p.AllowedPorts {
if port == allowedPort {
return true
}
}
return false
}
// isDomainWhitelisted 检查域名是否在白名单中
func isDomainListed(domain string, list []string) bool {
if len(list) == 0 {
return false
}
domain = strings.ToLower(domain)
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
// 精确匹配
if domain == item {
return true
}
// 通配符匹配 (*.example.com)
if strings.HasPrefix(item, "*.") {
suffix := strings.TrimPrefix(item, "*.")
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
return true
}
}
}
return false
}
func (p *SSRFProtection) isDomainAllowed(domain string) bool {
listed := isDomainListed(domain, p.DomainList)
if p.DomainFilterMode { // 白名单
return listed
}
// 黑名单
return !listed
}
// isIPWhitelisted 检查IP是否在白名单中
func isIPListed(ip net.IP, list []string) bool {
if len(list) == 0 {
return false
}
for _, whitelistCIDR := range list {
_, network, err := net.ParseCIDR(whitelistCIDR)
if err != nil {
// 尝试作为单个IP处理
if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
if ip.Equal(whitelistIP) {
return true
}
}
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// IsIPAccessAllowed 检查IP是否允许访问
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
// 私有IP限制
if isPrivateIP(ip) && !p.AllowPrivateIp {
return false
}
listed := isIPListed(ip, p.IpList)
if p.IpFilterMode { // 白名单
return listed
}
// 黑名单
return !listed
}
// ValidateURL 验证URL是否安全
func (p *SSRFProtection) ValidateURL(urlStr string) error {
// 解析URL
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %v", err)
}
// 只允许HTTP/HTTPS协议
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
}
// 解析主机和端口
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
// 没有端口,使用默认端口
host = u.Hostname()
if u.Scheme == "https" {
portStr = "443"
} else {
portStr = "80"
}
}
// 验证端口
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("invalid port: %s", portStr)
}
if !p.isAllowedPort(port) {
return fmt.Errorf("port %d is not allowed", port)
}
// 如果 host 是 IP,则跳过域名检查
if ip := net.ParseIP(host); ip != nil {
if !p.IsIPAccessAllowed(ip) {
if isPrivateIP(ip) {
return fmt.Errorf("private IP address not allowed: %s", ip.String())
}
if p.IpFilterMode {
return fmt.Errorf("ip not in whitelist: %s", ip.String())
}
return fmt.Errorf("ip in blacklist: %s", ip.String())
}
return nil
}
// 先进行域名过滤
if !p.isDomainAllowed(host) {
if p.DomainFilterMode {
return fmt.Errorf("domain not in whitelist: %s", host)
}
return fmt.Errorf("domain in blacklist: %s", host)
}
// 若未启用对域名应用IP过滤,则到此通过
if !p.ApplyIPFilterForDomain {
return nil
}
// 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
}
for _, ip := range ips {
if !p.IsIPAccessAllowed(ip) {
if isPrivateIP(ip) && !p.AllowPrivateIp {
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
}
if p.IpFilterMode {
return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
}
return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
}
}
return nil
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
// 如果SSRF防护被禁用,直接返回成功
if !enableSSRFProtection {
return nil
}
// 解析端口范围配置
allowedPortInts, err := parsePortRanges(allowedPorts)
if err != nil {
return fmt.Errorf("request reject - invalid port configuration: %v", err)
}
protection := &SSRFProtection{
AllowPrivateIp: allowPrivateIp,
DomainFilterMode: domainFilterMode,
DomainList: domainList,
IpFilterMode: ipFilterMode,
IpList: ipList,
AllowedPorts: allowedPortInts,
ApplyIPFilterForDomain: applyIPFilterForDomain,
}
return protection.ValidateURL(urlStr)
}
+72 -27
View File
@@ -99,12 +99,75 @@ func GetJsonString(data any) string {
return string(b)
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs in a string
// MaskEmail masks a user email to prevent PII leakage in logs
// Returns "***masked***" if email is empty, otherwise shows only the domain part
func MaskEmail(email string) string {
if email == "" {
return "***masked***"
}
// Find the @ symbol
atIndex := strings.Index(email, "@")
if atIndex == -1 {
// No @ symbol found, return masked
return "***masked***"
}
// Return only the domain part with @ symbol
return "***@" + email[atIndex+1:]
}
// maskHostTail returns the tail parts of a domain/host that should be preserved.
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
func maskHostTail(parts []string) []string {
if len(parts) < 2 {
return parts
}
lastPart := parts[len(parts)-1]
secondLastPart := parts[len(parts)-2]
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
// Likely country code TLD like co.uk, com.cn
return []string{secondLastPart, lastPart}
}
return []string{lastPart}
}
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
func maskHostForURL(host string) string {
parts := strings.Split(host, ".")
if len(parts) < 2 {
return "***"
}
tail := maskHostTail(parts)
return "***." + strings.Join(tail, ".")
}
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
func maskHostForPlainDomain(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return domain
}
tail := maskHostTail(parts)
numStars := len(parts) - len(tail)
if numStars < 1 {
numStars = 1
}
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
return stars + "." + strings.Join(tail, ".")
}
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
// Example:
// http://example.com -> http://***.com
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
// 192.168.1.1 -> ***.***.***.***
// openai.com -> ***.com
// www.openai.com -> ***.***.com
// api.openai.com -> ***.***.com
func MaskSensitiveInfo(str string) string {
// Mask URLs
urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
@@ -119,32 +182,8 @@ func MaskSensitiveInfo(str string) string {
return urlStr
}
// Split host by dots
parts := strings.Split(host, ".")
if len(parts) < 2 {
// If less than 2 parts, just mask the whole host
return u.Scheme + "://***" + u.Path
}
// Keep the TLD (Top Level Domain) and mask the rest
var maskedHost string
if len(parts) == 2 {
// example.com -> ***.com
maskedHost = "***." + parts[len(parts)-1]
} else {
// Handle cases like sub.domain.co.uk or api.example.com
// Keep last 2 parts if they look like country code TLD (co.uk, com.cn, etc.)
lastPart := parts[len(parts)-1]
secondLastPart := parts[len(parts)-2]
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
// Likely country code TLD like co.uk, com.cn
maskedHost = "***." + secondLastPart + "." + lastPart
} else {
// Regular TLD like .com, .org
maskedHost = "***." + lastPart
}
}
// Mask host with unified logic
maskedHost := maskHostForURL(host)
result := u.Scheme + "://" + maskedHost
@@ -184,6 +223,12 @@ func MaskSensitiveInfo(str string) string {
return result
})
// Mask domain names without protocol (like openai.com, www.openai.com)
domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string {
return maskHostForPlainDomain(domain)
})
// Mask IP addresses
ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
str = ipPattern.ReplaceAllString(str, "***.***.***.***")
+55
View File
@@ -0,0 +1,55 @@
package common
import (
"fmt"
"os"
"time"
"github.com/gin-gonic/gin"
)
func SysLog(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
}
func SysError(s string) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
}
func FatalLog(v ...any) {
t := time.Now()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
os.Exit(1)
}
func LogStartupSuccess(startTime time.Time, port string) {
duration := time.Since(startTime)
durationMs := duration.Milliseconds()
// Get network IPs
networkIps := GetNetworkIps()
// Print blank line for spacing
fmt.Fprintf(gin.DefaultWriter, "\n")
// Print the main success message
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
fmt.Fprintf(gin.DefaultWriter, "\n")
// Skip fancy startup message in container environments
if !IsRunningInContainer() {
// Print local URL
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
}
// Print network URLs
for _, ip := range networkIps {
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
}
// Print blank line for spacing
fmt.Fprintf(gin.DefaultWriter, "\n")
}
+81 -44
View File
@@ -1,8 +1,6 @@
package common
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
@@ -68,6 +66,78 @@ func GetIp() (ip string) {
return
}
func GetNetworkIps() []string {
var networkIps []string
ips, err := net.InterfaceAddrs()
if err != nil {
log.Println(err)
return networkIps
}
for _, a := range ips {
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
ip := ipNet.IP.String()
// Include common private network ranges
if strings.HasPrefix(ip, "10.") ||
strings.HasPrefix(ip, "172.") ||
strings.HasPrefix(ip, "192.168.") {
networkIps = append(networkIps, ip)
}
}
}
}
return networkIps
}
// IsRunningInContainer detects if the application is running inside a container
func IsRunningInContainer() bool {
// Method 1: Check for .dockerenv file (Docker containers)
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Method 2: Check cgroup for container indicators
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
content := string(data)
if strings.Contains(content, "docker") ||
strings.Contains(content, "containerd") ||
strings.Contains(content, "kubepods") ||
strings.Contains(content, "/lxc/") {
return true
}
}
// Method 3: Check environment variables commonly set by container runtimes
containerEnvVars := []string{
"KUBERNETES_SERVICE_HOST",
"DOCKER_CONTAINER",
"container",
}
for _, envVar := range containerEnvVars {
if os.Getenv(envVar) != "" {
return true
}
}
// Method 4: Check if init process is not the traditional init
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
comm := strings.TrimSpace(string(data))
// In containers, process 1 is often not "init" or "systemd"
if comm != "init" && comm != "systemd" {
// Additional check: if it's a common container entrypoint
if strings.Contains(comm, "docker") ||
strings.Contains(comm, "containerd") ||
strings.Contains(comm, "runc") {
return true
}
}
}
return false
}
var sizeKB = 1024
var sizeMB = sizeKB * 1024
var sizeGB = sizeMB * 1024
@@ -123,8 +193,16 @@ func Interface2String(inter interface{}) string {
return fmt.Sprintf("%d", inter.(int))
case float64:
return fmt.Sprintf("%f", inter.(float64))
case bool:
if inter.(bool) {
return "true"
} else {
return "false"
}
case nil:
return ""
}
return "Not Implemented"
return fmt.Sprintf("%v", inter)
}
func UnescapeHTML(x string) interface{} {
@@ -152,10 +230,6 @@ func GetUUID() string {
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func init() {
rand.New(rand.NewSource(time.Now().UnixNano()))
}
func GenerateRandomCharsKey(length int) (string, error) {
b := make([]byte, length)
maxI := big.NewInt(int64(len(keyChars)))
@@ -249,43 +323,6 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
return f.Name(), nil
}
// GetAudioDuration returns the duration of an audio file in seconds.
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}
// BuildURL concatenates base and endpoint, returns the complete url string
func BuildURL(base string, endpoint string) string {
u, err := url.Parse(base)
+2 -1
View File
@@ -1,10 +1,11 @@
package common
import (
"github.com/google/uuid"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type verificationValue struct {
+4
View File
@@ -31,5 +31,9 @@ const (
APITypeXai
APITypeCoze
APITypeJimeng
APITypeMoonshot
APITypeSubmodel
APITypeMiniMax
APITypeReplicate
APITypeDummy // this one is only for count, do not add any channel after this
)
+95
View File
@@ -50,6 +50,10 @@ const (
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeSubmodel = 53
ChannelTypeDoubaoVideo = 54
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -108,4 +112,95 @@ var ChannelBaseURLs = []string{
"https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52
"https://llm.submodel.ai", //53
"https://ark.cn-beijing.volces.com", //54
"https://api.openai.com", //55
"https://api.replicate.com", //56
}
var ChannelTypeNames = map[int]string{
ChannelTypeUnknown: "Unknown",
ChannelTypeOpenAI: "OpenAI",
ChannelTypeMidjourney: "Midjourney",
ChannelTypeAzure: "Azure",
ChannelTypeOllama: "Ollama",
ChannelTypeMidjourneyPlus: "MidjourneyPlus",
ChannelTypeOpenAIMax: "OpenAIMax",
ChannelTypeOhMyGPT: "OhMyGPT",
ChannelTypeCustom: "Custom",
ChannelTypeAILS: "AILS",
ChannelTypeAIProxy: "AIProxy",
ChannelTypePaLM: "PaLM",
ChannelTypeAPI2GPT: "API2GPT",
ChannelTypeAIGC2D: "AIGC2D",
ChannelTypeAnthropic: "Anthropic",
ChannelTypeBaidu: "Baidu",
ChannelTypeZhipu: "Zhipu",
ChannelTypeAli: "Ali",
ChannelTypeXunfei: "Xunfei",
ChannelType360: "360",
ChannelTypeOpenRouter: "OpenRouter",
ChannelTypeAIProxyLibrary: "AIProxyLibrary",
ChannelTypeFastGPT: "FastGPT",
ChannelTypeTencent: "Tencent",
ChannelTypeGemini: "Gemini",
ChannelTypeMoonshot: "Moonshot",
ChannelTypeZhipu_v4: "ZhipuV4",
ChannelTypePerplexity: "Perplexity",
ChannelTypeLingYiWanWu: "LingYiWanWu",
ChannelTypeAws: "AWS",
ChannelTypeCohere: "Cohere",
ChannelTypeMiniMax: "MiniMax",
ChannelTypeSunoAPI: "SunoAPI",
ChannelTypeDify: "Dify",
ChannelTypeJina: "Jina",
ChannelCloudflare: "Cloudflare",
ChannelTypeSiliconFlow: "SiliconFlow",
ChannelTypeVertexAi: "VertexAI",
ChannelTypeMistral: "Mistral",
ChannelTypeDeepSeek: "DeepSeek",
ChannelTypeMokaAI: "MokaAI",
ChannelTypeVolcEngine: "VolcEngine",
ChannelTypeBaiduV2: "BaiduV2",
ChannelTypeXinference: "Xinference",
ChannelTypeXai: "xAI",
ChannelTypeCoze: "Coze",
ChannelTypeKling: "Kling",
ChannelTypeJimeng: "Jimeng",
ChannelTypeVidu: "Vidu",
ChannelTypeSubmodel: "Submodel",
ChannelTypeDoubaoVideo: "DoubaoVideo",
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
}
func GetChannelTypeName(channelType int) string {
if name, ok := ChannelTypeNames[channelType]; ok {
return name
}
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",
},
}
+9
View File
@@ -3,6 +3,9 @@ package constant
type ContextKey string
const (
ContextKeyTokenCountMeta ContextKey = "token_count_meta"
ContextKeyPromptTokens ContextKey = "prompt_tokens"
ContextKeyOriginalModel ContextKey = "original_model"
ContextKeyRequestStartTime ContextKey = "request_start_time"
@@ -22,7 +25,9 @@ const (
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelHeaderOverride ContextKey = "header_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
ContextKeyChannelModelMapping ContextKey = "model_mapping"
@@ -40,4 +45,8 @@ const (
ContextKeyUserGroup ContextKey = "user_group"
ContextKeyUsingGroup ContextKey = "group"
ContextKeyUserName ContextKey = "username"
ContextKeyLocalCountTokens ContextKey = "local_count_tokens"
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
)
+2
View File
@@ -9,6 +9,8 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
EndpointTypeOpenAIVideo EndpointType = "openai-video"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"
+5
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
@@ -13,3 +15,6 @@ var NotifyLimitCount int
var NotificationLimitDurationMinute int
var GenerateDefaultToken bool
var ErrorLogEnabled bool
// temporary variable for sora patch, will be removed in future
var TaskPricePatches []string
+4 -2
View File
@@ -11,8 +11,10 @@ const (
SunoActionMusic = "MUSIC"
SunoActionLyrics = "LYRICS"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionFirstTailGenerate = "firstTailGenerate"
TaskActionReferenceGenerate = "referenceGenerate"
)
var SunoModel2Action = map[string]string{
+23 -7
View File
@@ -1,10 +1,11 @@
package controller
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/dto"
"one-api/model"
)
func GetSubscription(c *gin.Context) {
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
}
quota := remainQuota + usedQuota
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
// 我们将其解释为以“站点展示类型”为准:
// - USD: 直接除以 QuotaPerUnit
// - CNY: 先转 USD 再乘汇率
// - TOKENS: 直接使用 tokens 数量
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// amount 保持 tokens 数值
default:
amount = amount / common.QuotaPerUnit
}
if token != nil && token.UnlimitedQuota {
amount = 100000000
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
return
}
amount := float64(quota)
if common.DisplayInCurrencyEnabled {
amount /= common.QuotaPerUnit
switch operation_setting.GetQuotaDisplayType() {
case operation_setting.QuotaDisplayTypeCNY:
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
case operation_setting.QuotaDisplayTypeTokens:
// tokens 保持原值
default:
amount = amount / common.QuotaPerUnit
}
usage := OpenAIUsageResponse{
Object: "list",
+21 -8
View File
@@ -6,15 +6,16 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/types"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/shopspring/decimal"
"github.com/gin-gonic/gin"
@@ -127,6 +128,14 @@ func GetAuthHeader(token string) http.Header {
return h
}
// GetClaudeAuthHeader get claude auth header
func GetClaudeAuthHeader(token string) http.Header {
h := http.Header{}
h.Add("x-api-key", token)
h.Add("anthropic-version", "2023-06-01")
return h
}
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
@@ -135,7 +144,11 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
res, err := service.GetHttpClient().Do(req)
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
@@ -338,7 +351,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
}
availableBalanceCny := response.Data.AvailableBalance
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}
+284 -114
View File
@@ -10,23 +10,26 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/relay"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
)
@@ -37,56 +40,63 @@ type testResult struct {
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
tik := time.Now()
if channel.Type == constant.ChannelTypeMidjourney {
return testResult{
localErr: errors.New("midjourney channel test is not supported"),
newAPIError: nil,
}
var unsupportedTestChannelTypes = []int{
constant.ChannelTypeMidjourney,
constant.ChannelTypeMidjourneyPlus,
constant.ChannelTypeSunoAPI,
constant.ChannelTypeKling,
constant.ChannelTypeJimeng,
constant.ChannelTypeDoubaoVideo,
constant.ChannelTypeVidu,
}
if channel.Type == constant.ChannelTypeMidjourneyPlus {
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
channelTypeName := constant.GetChannelTypeName(channel.Type)
return testResult{
localErr: errors.New("midjourney plus channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeSunoAPI {
return testResult{
localErr: errors.New("suno channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeKling {
return testResult{
localErr: errors.New("kling channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeJimeng {
return testResult{
localErr: errors.New("jimeng channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeVidu {
return testResult{
localErr: errors.New("vidu channel test is not supported"),
newAPIError: nil,
localErr: fmt.Errorf("%s channel test is not supported", channelTypeName),
}
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
testModel = strings.TrimSpace(testModel)
if testModel == "" {
if channel.TestModel != nil && *channel.TestModel != "" {
testModel = strings.TrimSpace(*channel.TestModel)
} else {
models := channel.GetModels()
if len(models) > 0 {
testModel = strings.TrimSpace(models[0])
}
if testModel == "" {
testModel = "gpt-4o-mini"
}
}
}
requestPath := "/v1/chat/completions"
// 先判断是否为 Embedding 模
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
// 如果指定了端点类型,使用指定的端点类
if endpointType != "" {
if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok {
requestPath = endpointInfo.Path
}
} else {
// 如果没有指定端点类型,使用原有的自动检测逻辑
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
strings.Contains(testModel, "bge-") || // bge 系列模型
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
requestPath = "/v1/embeddings" // 修改请求路径
}
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
}
c.Request = &http.Request{
@@ -96,18 +106,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
Header: make(http.Header),
}
if testModel == "" {
if channel.TestModel != nil && *channel.TestModel != "" {
testModel = *channel.TestModel
} else {
if len(channel.GetModels()) > 0 {
testModel = channel.GetModels()[0]
} else {
testModel = "gpt-4o-mini"
}
}
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -133,9 +131,66 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
info := relaycommon.GenRelayInfo(c)
// Determine relay format based on endpoint type or request path
var relayFormat types.RelayFormat
if endpointType != "" {
// 根据指定的端点类型设置 relayFormat
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeOpenAI:
relayFormat = types.RelayFormatOpenAI
case constant.EndpointTypeOpenAIResponse:
relayFormat = types.RelayFormatOpenAIResponses
case constant.EndpointTypeAnthropic:
relayFormat = types.RelayFormatClaude
case constant.EndpointTypeGemini:
relayFormat = types.RelayFormatGemini
case constant.EndpointTypeJinaRerank:
relayFormat = types.RelayFormatRerank
case constant.EndpointTypeImageGeneration:
relayFormat = types.RelayFormatOpenAIImage
case constant.EndpointTypeEmbeddings:
relayFormat = types.RelayFormatEmbedding
default:
relayFormat = types.RelayFormatOpenAI
}
} else {
// 根据请求路径自动检测
relayFormat = types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
if c.Request.URL.Path == "/v1/messages" {
relayFormat = types.RelayFormatClaude
}
if strings.Contains(c.Request.URL.Path, "/v1beta/models") {
relayFormat = types.RelayFormatGemini
}
if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" {
relayFormat = types.RelayFormatRerank
}
if c.Request.URL.Path == "/v1/responses" {
relayFormat = types.RelayFormatOpenAIResponses
}
}
err = helper.ModelMappedHelper(c, info, nil)
request := buildTestRequest(testModel, endpointType)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
if err != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeGenRelayInfoFailed),
}
}
info.InitChannelMeta(c)
err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return testResult{
context: c,
@@ -143,7 +198,10 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
testModel = info.UpstreamModelName
// 更新请求中的模型名称
request.SetModelName(testModel)
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
@@ -155,13 +213,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
request := buildTestRequest(testModel)
// 创建一个用于日志的 info 副本,移除 ApiKey
logInfo := *info
logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo))
//// 创建一个用于日志的 info 副本,移除 ApiKey
//logInfo := info
//logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.GetMaxTokens()))
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
if err != nil {
return testResult{
context: c,
@@ -174,17 +231,62 @@ func testChannel(channel *model.Channel, testModel string) testResult {
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
// Embedding 请求 - request 已经是正确的类型
if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok {
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid embedding request type"),
newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeImagesGenerations:
// 图像生成请求 - request 已经是正确的类型
if imageReq, ok := request.(*dto.ImageRequest); ok {
convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid image request type"),
newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeRerank:
// Rerank 请求 - request 已经是正确的类型
if rerankReq, ok := request.(*dto.RerankRequest); ok {
convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid rerank request type"),
newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed),
}
}
case relayconstant.RelayModeResponses:
// Response 请求 - request 已经是正确的类型
if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid response request type"),
newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed),
}
}
default:
// Chat/Completion 等其他请求类型
if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok {
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq)
} else {
return testResult{
context: c,
localErr: errors.New("invalid general request type"),
newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed),
}
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else {
// 对其他所有请求类型(如 Chat),保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
@@ -216,7 +318,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true)
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
return testResult{
context: c,
localErr: err,
@@ -287,22 +389,82 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest := &dto.GeneralOpenAIRequest{
Model: "", // this will be set later
Stream: false,
func buildTestRequest(model string, endpointType string) dto.Request {
// 根据端点类型构建不同的测试请求
if endpointType != "" {
switch constant.EndpointType(endpointType) {
case constant.EndpointTypeEmbeddings:
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
case constant.EndpointTypeImageGeneration:
// 返回 ImageRequest
return &dto.ImageRequest{
Model: model,
Prompt: "a cute cat",
N: 1,
Size: "1024x1024",
}
case constant.EndpointTypeJinaRerank:
// 返回 RerankRequest
return &dto.RerankRequest{
Model: model,
Query: "What is Deep Learning?",
Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
TopN: 2,
}
case constant.EndpointTypeOpenAIResponse:
// 返回 OpenAIResponsesRequest
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage("\"hi\""),
}
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
// 返回 GeneralOpenAIRequest
maxTokens := uint(10)
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
maxTokens = 3000
}
return &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
MaxTokens: maxTokens,
}
}
}
// 自动检测逻辑(保持原有行为)
// 先判断是否为 Embedding 模型
if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型
strings.HasPrefix(model, "m3e") || // m3e 系列模型
if strings.Contains(strings.ToLower(model), "embedding") ||
strings.HasPrefix(model, "m3e") ||
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []any{"hello world"} // 修改为any,因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
// 返回 EmbeddingRequest
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
}
// 并非Embedding 模型
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest
testRequest := &dto.GeneralOpenAIRequest{
Model: model,
Stream: false,
Messages: []dto.Message{
{
Role: "user",
Content: "hi",
},
},
}
if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = 10
} else if strings.Contains(model, "thinking") {
@@ -315,12 +477,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
testRequest.MaxTokens = 10
}
testMessage := dto.Message{
Role: "user",
Content: "hi",
}
testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest
}
@@ -344,8 +500,9 @@ func TestChannel(c *gin.Context) {
// }
//}()
testModel := c.Query("model")
endpointType := c.Query("endpoint_type")
tik := time.Now()
result := testChannel(channel, testModel)
result := testChannel(channel, testModel, endpointType)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -371,7 +528,6 @@ func TestChannel(c *gin.Context) {
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
@@ -405,7 +561,7 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
result := testChannel(channel, "")
result := testChannel(channel, "", "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
@@ -419,7 +575,7 @@ func testAllChannels(notify bool) error {
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true
}
@@ -427,7 +583,7 @@ func testAllChannels(notify bool) error {
// disable channel
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
}
// enable channel
@@ -456,18 +612,32 @@ func TestAllChannels(c *gin.Context) {
"success": true,
"message": "",
})
return
}
func AutomaticallyTestChannels(frequency int) {
if frequency <= 0 {
common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
// 只在Master节点定时测试渠道
if !common.IsMasterNode {
return
}
for {
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("testing all channels")
_ = testAllChannels(false)
common.SysLog("channel test finished")
}
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(1 * time.Minute)
continue
}
for {
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
break
}
}
}
})
}
+232 -74
View File
@@ -4,12 +4,15 @@ import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -36,30 +39,11 @@ type OpenAIModel struct {
Parent string `json:"parent"`
}
type GoogleOpenAICompatibleModels []struct {
Name string `json:"name"`
Version string `json:"version"`
DisplayName string `json:"displayName"`
Description string `json:"description,omitempty"`
InputTokenLimit int `json:"inputTokenLimit"`
OutputTokenLimit int `json:"outputTokenLimit"`
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"`
TopK int `json:"topK,omitempty"`
MaxTemperature int `json:"maxTemperature,omitempty"`
}
type OpenAIModelsResponse struct {
Data []OpenAIModel `json:"data"`
Success bool `json:"success"`
}
type GoogleOpenAICompatibleResponse struct {
Models []GoogleOpenAICompatibleModels `json:"models"`
NextPageToken string `json:"nextPageToken"`
}
func parseStatusFilter(statusParam string) int {
switch strings.ToLower(statusParam) {
case "enabled", "1":
@@ -107,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
}
@@ -203,19 +187,49 @@ func FetchUpstreamModels(c *gin.Context) {
switch channel.Type {
case constant.ChannelTypeGemini:
// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
url = fmt.Sprintf("%s/v1beta/openai/models?key=%s", baseURL, channel.Key)
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
case constant.ChannelTypeAli:
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
case constant.ChannelTypeZhipu_v4:
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
}
case constant.ChannelTypeVolcEngine:
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/v1/models", baseURL)
}
case constant.ChannelTypeMoonshot:
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/v1/models", baseURL)
}
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}
// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
})
return
}
key = strings.TrimSpace(key)
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
var body []byte
if channel.Type == constant.ChannelTypeGemini {
body, err = GetResponseBody("GET", url, channel, nil) // I don't know why, but Gemini requires no AuthHeader
} else {
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
switch channel.Type {
case constant.ChannelTypeAnthropic:
body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key))
default:
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key))
}
if err != nil {
common.ApiError(c, err)
@@ -223,34 +237,12 @@ func FetchUpstreamModels(c *gin.Context) {
}
var result OpenAIModelsResponse
var parseSuccess bool
// 适配特殊格式
switch channel.Type {
case constant.ChannelTypeGemini:
var googleResult GoogleOpenAICompatibleResponse
if err = json.Unmarshal(body, &googleResult); err == nil {
// 转换Google格式到OpenAI格式
for _, model := range googleResult.Models {
for _, gModel := range model {
result.Data = append(result.Data, OpenAIModel{
ID: gModel.Name,
})
}
}
parseSuccess = true
}
}
// 如果解析失败,尝试OpenAI格式
if !parseSuccess {
if err = json.Unmarshal(body, &result); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("解析响应失败: %s", err.Error()),
})
return
}
if err = json.Unmarshal(body, &result); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("解析响应失败: %s", err.Error()),
})
return
}
var ids []string
@@ -305,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...)
}
@@ -420,6 +412,58 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "获取成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// validateTwoFactorAuth 统一的2FA验证函数
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// 尝试验证TOTP
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
return true
}
}
// 尝试验证备用码
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
return true
}
return false
}
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
@@ -461,9 +505,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
Channel *model.Channel `json:"channel"`
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
Channel *model.Channel `json:"channel"`
}
func getVertexArrayKeys(keys string) ([]string, error) {
@@ -521,7 +566,7 @@ func AddChannel(c *gin.Context) {
case "multi_to_single":
addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -546,7 +591,7 @@ func AddChannel(c *gin.Context) {
}
keys = []string{addChannelRequest.Channel.Key}
case "batch":
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil {
@@ -576,6 +621,13 @@ func AddChannel(c *gin.Context) {
}
localChannel := addChannelRequest.Channel
localChannel.Key = key
if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
keyPrefix := localChannel.Key
if len(localChannel.Key) > 8 {
keyPrefix = localChannel.Key[:8]
}
localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
}
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
@@ -583,6 +635,7 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
service.ResetProxyClientCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -622,13 +675,15 @@ func DeleteDisabledChannel(c *gin.Context) {
}
type ChannelTag struct {
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
ParamOverride *string `json:"param_override"`
HeaderOverride *string `json:"header_override"`
}
func DisableTagChannels(c *gin.Context) {
@@ -694,7 +749,29 @@ func EditTagChannels(c *gin.Context) {
})
return
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
if channelTag.ParamOverride != nil {
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.ParamOverride = common.GetPointer[string](trimmed)
}
if channelTag.HeaderOverride != nil {
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请求头覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride)
if err != nil {
common.ApiError(c, err)
return
@@ -801,7 +878,7 @@ func UpdateChannel(c *gin.Context) {
}
// 处理 Vertex AI 的特殊情况
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
// 尝试解析新密钥为JSON数组
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key)
@@ -844,6 +921,7 @@ func UpdateChannel(c *gin.Context) {
return
}
model.InitChannelCache()
service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
@@ -969,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,
@@ -1053,8 +1131,8 @@ func CopyChannel(c *gin.Context) {
// MultiKeyManageRequest represents the request for multi-key management operations
type MultiKeyManageRequest struct {
ChannelId int `json:"channel_id"`
Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions
Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions
Page int `json:"page,omitempty"` // for get_key_status pagination
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
@@ -1382,6 +1460,86 @@ func ManageMultiKeys(c *gin.Context) {
})
return
case "delete_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要删除的密钥索引",
})
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
return
}
keys := channel.GetKeys()
var remainingKeys []string
var newStatusList = make(map[int]int)
var newDisabledTime = make(map[int]int64)
var newDisabledReason = make(map[int]string)
newIndex := 0
for i, key := range keys {
// 跳过要删除的密钥
if i == keyIndex {
continue
}
remainingKeys = append(remainingKeys, key)
// 保留其他密钥的状态信息,重新索引
if channel.ChannelInfo.MultiKeyStatusList != nil {
if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 {
newStatusList[newIndex] = status
}
}
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
newDisabledTime[newIndex] = t
}
}
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
newDisabledReason[newIndex] = r
}
}
newIndex++
}
if len(remainingKeys) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不能删除最后一个密钥",
})
return
}
// Update channel with remaining keys
channel.Key = strings.Join(remainingKeys, "\n")
channel.ChannelInfo.MultiKeySize = len(remainingKeys)
channel.ChannelInfo.MultiKeyStatusList = newStatusList
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已删除",
})
return
case "delete_disabled_keys":
keys := channel.GetKeys()
var remainingKeys []string
+93 -91
View File
@@ -3,101 +3,103 @@
package controller
import (
"encoding/json"
"net/http"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"encoding/json"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
+223
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",
})
}
+4 -3
View File
@@ -6,11 +6,12 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -43,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 {
+9 -7
View File
@@ -2,9 +2,11 @@ package controller
import (
"net/http"
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -26,17 +28,17 @@ func GetUserGroups(c *gin.Context) {
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
for groupName, ratio := range ratio_setting.GetGroupRatioCopy() {
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
// UserUsableGroups contains the groups that the user can use
userUsableGroups := setting.GetUserUsableGroups(userGroup)
if desc, ok := userUsableGroups[groupName]; ok {
usableGroups[groupName] = map[string]interface{}{
"ratio": ratio,
"ratio": service.GetUserGroupRatio(userGroup, groupName),
"desc": desc,
}
}
}
if setting.GroupInUserUsableGroups("auto") {
if _, ok := userUsableGroups["auto"]; ok {
usableGroups["auto"] = map[string]interface{}{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),
+24 -15
View File
@@ -7,12 +7,13 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -83,7 +84,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
}
// Get access token using Basic auth
tokenEndpoint := "https://connect.linux.do/oauth2/token"
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
@@ -128,7 +129,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
}
// Get user info
userEndpoint := "https://connect.linux.do/api/user"
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
req, err = http.NewRequest("GET", userEndpoint, nil)
if err != nil {
return nil, err
@@ -220,21 +221,29 @@ func LinuxdoOAuth(c *gin.Context) {
}
} else {
if common.RegisterEnabled {
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = linuxdoUser.Name
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = linuxdoUser.Name
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
}
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
}
if err := user.Insert(inviterId); err != nil {
if err := user.Insert(inviterId); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
})
return
}
+3 -2
View File
@@ -2,10 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
+55 -22
View File
@@ -7,13 +7,16 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/service"
"one-api/setting"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -28,7 +31,7 @@ func UpdateMidjourneyTaskBulk() {
continue
}
common.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Midjourney)
nullTaskIds := make([]int, 0)
@@ -47,9 +50,9 @@ func UpdateMidjourneyTaskBulk() {
"progress": "100%",
})
if err != nil {
common.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
} else {
common.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
@@ -57,20 +60,20 @@ func UpdateMidjourneyTaskBulk() {
}
for channelId, taskIds := range taskChannelM {
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
continue
}
midjourneyChannel, err := model.CacheGetChannel(channelId)
if err != nil {
common.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
err := model.MjBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
common.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
}
continue
}
@@ -81,7 +84,7 @@ func UpdateMidjourneyTaskBulk() {
})
req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
continue
}
// 设置超时时间
@@ -93,22 +96,22 @@ func UpdateMidjourneyTaskBulk() {
req.Header.Set("mj-api-secret", midjourneyChannel.Key)
resp, err := service.GetHttpClient().Do(req)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
continue
}
if resp.StatusCode != http.StatusOK {
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
continue
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
continue
}
var responseItems []dto.MidjourneyDto
err = json.Unmarshal(responseBody, &responseItems)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
continue
}
resp.Body.Close()
@@ -145,9 +148,25 @@ func UpdateMidjourneyTaskBulk() {
buttonStr, _ := json.Marshal(responseItem.Buttons)
task.Buttons = string(buttonStr)
}
// 映射 VideoUrl
task.VideoUrl = responseItem.VideoUrl
// 映射 VideoUrls - 将数组序列化为 JSON 字符串
if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
task.VideoUrls = "[]" // 失败时设置为空数组
} else {
task.VideoUrls = string(videoUrlsStr)
}
} else {
task.VideoUrls = "" // 空值时清空字段
}
shouldReturnQuota := false
if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
task.Progress = "100%"
if task.Quota != 0 {
shouldReturnQuota = true
@@ -155,14 +174,14 @@ func UpdateMidjourneyTaskBulk() {
}
err = task.Update()
if err != nil {
common.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else {
if shouldReturnQuota {
err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
if err != nil {
common.LogError(ctx, "fail to increase user quota: "+err.Error())
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, common.LogQuota(task.Quota))
logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.LogQuota(task.Quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
@@ -208,6 +227,20 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
if oldTask.Progress != "100%" && newTask.FailReason != "" {
return true
}
// 检查 VideoUrl 是否需要更新
if oldTask.VideoUrl != newTask.VideoUrl {
return true
}
// 检查 VideoUrls 是否需要更新
if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
if oldTask.VideoUrls != string(newVideoUrlsStr) {
return true
}
} else if oldTask.VideoUrls != "" {
// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
return true
}
return false
}
@@ -228,7 +261,7 @@ func GetAllMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
@@ -253,7 +286,7 @@ func GetUserMidjourney(c *gin.Context) {
if setting.MjForwardUrlEnabled {
for i, midjourney := range items {
midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
items[i] = midjourney
}
}
+89 -49
View File
@@ -4,16 +4,17 @@ import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/operation_setting"
"one-api/setting/system_setting"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -39,48 +40,56 @@ func TestStatus(c *gin.Context) {
func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
legalSetting := system_setting.GetLegalSettings()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
"min_topup": setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"pay_methods": setting.PayMethods,
"usd_exchange_rate": setting.USDExchangeRate,
"version": common.Version,
"start_time": common.StartTime,
"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,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
"display_in_currency": operation_setting.IsCurrencyDisplay(),
"quota_display_type": operation_setting.GetQuotaDisplayType(),
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
@@ -88,10 +97,23 @@ func GetStatus(c *gin.Context) {
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
// 模块管理配置
"HeaderNavModules": common.OptionMap["HeaderNavModules"],
"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"passkey_login": passkeySetting.Enabled,
"passkey_display_name": passkeySetting.RPDisplayName,
"passkey_rp_id": passkeySetting.RPID,
"passkey_origins": passkeySetting.Origins,
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
"passkey_user_verification": passkeySetting.UserVerification,
"passkey_attachment": passkeySetting.AttachmentPreference,
"setup": constant.Setup,
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
}
// 根据启用状态注入可选内容
@@ -135,6 +157,24 @@ func GetAbout(c *gin.Context) {
return
}
func GetUserAgreement(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
})
return
}
func GetPrivacyPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
})
return
}
func GetMidjourney(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
@@ -246,7 +286,7 @@ func SendPasswordResetEmail(c *gin.Context) {
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
+28
View File
@@ -0,0 +1,28 @@
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetMissingModels returns the list of model names that are referenced by channels
// but do not have corresponding records in the models meta table.
// This helps administrators quickly discover models that need configuration.
func GetMissingModels(c *gin.Context) {
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
}
+93 -21
View File
@@ -2,20 +2,24 @@ package controller
import (
"fmt"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"one-api/relay/channel/ai360"
"one-api/relay/channel/lingyiwanwu"
"one-api/relay/channel/minimax"
"one-api/relay/channel/moonshot"
relaycommon "one-api/relay/common"
"one-api/setting"
)
// https://platform.openai.com/docs/api-reference/models/list
@@ -92,7 +96,9 @@ func init() {
if !success || apiType == constant.APITypeAIProxyLibrary {
continue
}
meta := &relaycommon.RelayInfo{ChannelType: i}
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: i,
}}
adaptor := relay.GetAdaptor(apiType)
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
@@ -102,9 +108,20 @@ func init() {
})
}
func ListModels(c *gin.Context) {
func ListModels(c *gin.Context, modelType int) {
userOpenAiModels := make([]dto.OpenAIModels, 0)
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
if !acceptUnsetRatioModel {
userId := c.GetInt("id")
if userId > 0 {
userSettings, _ := model.GetUserSetting(userId, false)
if userSettings.AcceptUnsetRatioModel {
acceptUnsetRatioModel = true
}
}
}
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
@@ -115,6 +132,12 @@ func ListModels(c *gin.Context) {
tokenModelLimit = map[string]bool{}
}
for allowModel, _ := range tokenModelLimit {
if !acceptUnsetRatioModel {
_, _, exist := ratio_setting.GetModelRatioOrPrice(allowModel)
if !exist {
continue
}
}
if oaiModel, ok := openAIModelsMap[allowModel]; ok {
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel)
userOpenAiModels = append(userOpenAiModels, oaiModel)
@@ -145,7 +168,7 @@ func ListModels(c *gin.Context) {
}
var models []string
if tokenGroup == "auto" {
for _, autoGroup := range setting.AutoGroups {
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
groupModels := model.GetGroupEnabledModels(autoGroup)
for _, g := range groupModels {
if !common.StringsContains(models, g) {
@@ -157,6 +180,12 @@ func ListModels(c *gin.Context) {
models = model.GetGroupEnabledModels(group)
}
for _, modelName := range models {
if !acceptUnsetRatioModel {
_, _, exist := ratio_setting.GetModelRatioOrPrice(modelName)
if !exist {
continue
}
}
if oaiModel, ok := openAIModelsMap[modelName]; ok {
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
userOpenAiModels = append(userOpenAiModels, oaiModel)
@@ -171,10 +200,43 @@ func ListModels(c *gin.Context) {
}
}
}
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
})
switch modelType {
case constant.ChannelTypeAnthropic:
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
useranthropicModels[i] = dto.AnthropicModel{
ID: model.Id,
CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
DisplayName: model.Id,
Type: "model",
}
}
c.JSON(200, gin.H{
"data": useranthropicModels,
"first_id": useranthropicModels[0].ID,
"has_more": false,
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
})
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
userGeminiModels[i] = dto.GeminiModel{
Name: model.Id,
DisplayName: model.Id,
}
}
c.JSON(200, gin.H{
"models": userGeminiModels,
"nextPageToken": nil,
})
default:
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
"object": "list",
})
}
}
func ChannelListModels(c *gin.Context) {
@@ -198,10 +260,20 @@ func EnabledListModels(c *gin.Context) {
})
}
func RetrieveModel(c *gin.Context) {
func RetrieveModel(c *gin.Context, modelType int) {
modelId := c.Param("model")
if aiModel, ok := openAIModelsMap[modelId]; ok {
c.JSON(200, aiModel)
switch modelType {
case constant.ChannelTypeAnthropic:
c.JSON(200, dto.AnthropicModel{
ID: aiModel.Id,
CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
DisplayName: aiModel.Id,
Type: "model",
})
default:
c.JSON(200, aiModel)
}
} else {
openAIError := dto.OpenAIError{
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
+330
View File
@@ -0,0 +1,330 @@
package controller
import (
"encoding/json"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
var total int64
model.DB.Model(&model.Model{}).Count(&total)
// 统计供应商计数(全部数据,不受分页影响)
vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
}
// SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) {
keyword := c.Query("keyword")
vendor := c.Query("vendor")
pageInfo := common.GetPageQuery(c)
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, pageInfo)
}
// GetModelMeta 根据 ID 获取单条模型信息
func GetModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
enrichModels([]*model.Model{&m})
common.ApiSuccess(c, &m)
}
// CreateModelMeta 新建模型
func CreateModelMeta(c *gin.Context) {
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// UpdateModelMeta 更新模型
func UpdateModelMeta(c *gin.Context) {
statusOnly := c.Query("status_only") == "true"
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
return
}
if statusOnly {
// 只更新状态,防止误清空其他字段
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
common.ApiError(c, err)
return
}
} else {
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Update(); err != nil {
common.ApiError(c, err)
return
}
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// DeleteModelMeta 删除模型
func DeleteModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, nil)
}
// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询
func enrichModels(models []*model.Model) {
if len(models) == 0 {
return
}
// 1) 拆分精确与规则匹配
exactNames := make([]string, 0)
exactIdx := make(map[string][]int) // modelName -> indices in models
ruleIndices := make([]int, 0)
for i, m := range models {
if m == nil {
continue
}
if m.NameRule == model.NameRuleExact {
exactNames = append(exactNames, m.ModelName)
exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i)
} else {
ruleIndices = append(ruleIndices, i)
}
}
// 2) 批量查询精确模型的绑定渠道
channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames)
// 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存
for name, indices := range exactIdx {
chs := channelsByModel[name]
for _, idx := range indices {
mm := models[idx]
if mm.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(mm.ModelName)
if b, err := json.Marshal(eps); err == nil {
mm.Endpoints = string(b)
}
}
mm.BoundChannels = chs
mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName)
mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName)
}
}
if len(ruleIndices) == 0 {
return
}
// 4) 一次性读取定价缓存,内存匹配所有规则模型
pricings := model.GetPricing()
// 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合
matchedNamesByIdx := make(map[int][]string)
endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{})
groupSetByIdx := make(map[int]map[string]struct{})
quotaSetByIdx := make(map[int]map[int]struct{})
for _, p := range pricings {
for _, idx := range ruleIndices {
mm := models[idx]
var matched bool
switch mm.NameRule {
case model.NameRulePrefix:
matched = strings.HasPrefix(p.ModelName, mm.ModelName)
case model.NameRuleSuffix:
matched = strings.HasSuffix(p.ModelName, mm.ModelName)
case model.NameRuleContains:
matched = strings.Contains(p.ModelName, mm.ModelName)
}
if !matched {
continue
}
matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName)
es := endpointSetByIdx[idx]
if es == nil {
es = make(map[constant.EndpointType]struct{})
endpointSetByIdx[idx] = es
}
for _, et := range p.SupportedEndpointTypes {
es[et] = struct{}{}
}
gs := groupSetByIdx[idx]
if gs == nil {
gs = make(map[string]struct{})
groupSetByIdx[idx] = gs
}
for _, g := range p.EnableGroup {
gs[g] = struct{}{}
}
qs := quotaSetByIdx[idx]
if qs == nil {
qs = make(map[int]struct{})
quotaSetByIdx[idx] = qs
}
qs[p.QuotaType] = struct{}{}
}
}
// 5) 汇总所有匹配到的模型名称,批量查询一次渠道
allMatchedSet := make(map[string]struct{})
for _, names := range matchedNamesByIdx {
for _, n := range names {
allMatchedSet[n] = struct{}{}
}
}
allMatched := make([]string, 0, len(allMatchedSet))
for n := range allMatchedSet {
allMatched = append(allMatched, n)
}
matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched)
// 6) 回填每个规则模型的并集信息
for _, idx := range ruleIndices {
mm := models[idx]
// 端点并集 -> 序列化
if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" {
eps := make([]constant.EndpointType, 0, len(es))
for et := range es {
eps = append(eps, et)
}
if b, err := json.Marshal(eps); err == nil {
mm.Endpoints = string(b)
}
}
// 分组并集
if gs, ok := groupSetByIdx[idx]; ok {
groups := make([]string, 0, len(gs))
for g := range gs {
groups = append(groups, g)
}
mm.EnableGroups = groups
}
// 配额类型集合(保持去重并排序)
if qs, ok := quotaSetByIdx[idx]; ok {
arr := make([]int, 0, len(qs))
for k := range qs {
arr = append(arr, k)
}
sort.Ints(arr)
mm.QuotaTypes = arr
}
// 渠道并集
names := matchedNamesByIdx[idx]
channelSet := make(map[string]model.BoundChannel)
for _, n := range names {
for _, ch := range matchedChannelsByModel[n] {
key := ch.Name + "_" + strconv.Itoa(ch.Type)
channelSet[key] = ch
}
}
if len(channelSet) > 0 {
chs := make([]model.BoundChannel, 0, len(channelSet))
for _, ch := range channelSet {
chs = append(chs, ch)
}
mm.BoundChannels = chs
}
// 匹配信息
mm.MatchedModels = names
mm.MatchedCount = len(names)
}
}
+604
View File
@@ -0,0 +1,604 @@
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 上游地址
const (
upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
)
func normalizeLocale(locale string) (string, bool) {
l := strings.ToLower(strings.TrimSpace(locale))
switch l {
case "en", "zh", "ja":
return l, true
default:
return "", false
}
}
func getUpstreamBase() string {
return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
}
func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
base := strings.TrimRight(getUpstreamBase(), "/")
if l, ok := normalizeLocale(locale); ok && l != "" {
return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
}
return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
}
type upstreamEnvelope[T any] struct {
Success bool `json:"success"`
Message string `json:"message"`
Data []T `json:"data"`
}
type upstreamModel struct {
Description string `json:"description"`
Endpoints json.RawMessage `json:"endpoints"`
Icon string `json:"icon"`
ModelName string `json:"model_name"`
NameRule int `json:"name_rule"`
Status int `json:"status"`
Tags string `json:"tags"`
VendorName string `json:"vendor_name"`
}
type upstreamVendor struct {
Description string `json:"description"`
Icon string `json:"icon"`
Name string `json:"name"`
Status int `json:"status"`
}
var (
etagCache = make(map[string]string)
bodyCache = make(map[string][]byte)
cacheMutex sync.RWMutex
)
type overwriteField struct {
ModelName string `json:"model_name"`
Fields []string `json:"fields"`
}
type syncRequest struct {
Overwrite []overwriteField `json:"overwrite"`
Locale string `json:"locale"`
}
func newHTTPClient() *http.Client {
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
transport := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
return &http.Client{Transport: transport}
}
var httpClient = newHTTPClient()
func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
var lastErr error
attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
if attempts < 1 {
attempts = 1
}
baseDelay := 200 * time.Millisecond
maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
maxBytes := int64(maxMB) << 20
for attempt := 0; attempt < attempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// ETag conditional request
cacheMutex.RLock()
if et := etagCache[url]; et != "" {
req.Header.Set("If-None-Match", et)
}
cacheMutex.RUnlock()
resp, err := httpClient.Do(req)
if err != nil {
lastErr = err
// backoff with jitter
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
continue
}
func() {
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// read body into buffer for caching and flexible decode
limited := io.LimitReader(resp.Body, maxBytes)
buf, err := io.ReadAll(limited)
if err != nil {
lastErr = err
return
}
// cache body and ETag
cacheMutex.Lock()
if et := resp.Header.Get("ETag"); et != "" {
etagCache[url] = et
}
bodyCache[url] = buf
cacheMutex.Unlock()
// Try decode as envelope first
if err := json.Unmarshal(buf, out); err != nil {
// Try decode as pure array
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
case http.StatusNotModified:
// use cache
cacheMutex.RLock()
buf := bodyCache[url]
cacheMutex.RUnlock()
if len(buf) == 0 {
lastErr = errors.New("cache miss for 304 response")
return
}
if err := json.Unmarshal(buf, out); err != nil {
var arr []T
if err2 := json.Unmarshal(buf, &arr); err2 != nil {
lastErr = err
return
}
out.Success = true
out.Data = arr
out.Message = ""
} else {
if !out.Success && len(out.Data) == 0 && out.Message == "" {
out.Success = true
}
}
lastErr = nil
default:
lastErr = errors.New(resp.Status)
}
}()
if lastErr == nil {
return nil
}
sleep := baseDelay * time.Duration(1<<attempt)
jitter := time.Duration(rand.Intn(150)) * time.Millisecond
time.Sleep(sleep + jitter)
}
return lastErr
}
func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
if vendorName == "" {
return 0
}
if id, ok := vendorIDCache[vendorName]; ok {
return id
}
var existing model.Vendor
if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
vendorIDCache[vendorName] = existing.Id
return existing.Id
}
uv := vendorByName[vendorName]
v := &model.Vendor{
Name: vendorName,
Description: uv.Description,
Icon: coalesce(uv.Icon, ""),
Status: chooseStatus(uv.Status, 1),
}
if err := v.Insert(); err == nil {
*createdVendors++
vendorIDCache[vendorName] = v.Id
return v.Id
}
vendorIDCache[vendorName] = 0
return 0
}
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
func SyncUpstreamModels(c *gin.Context) {
var req syncRequest
// 允许空体
_ = c.ShouldBindJSON(&req)
// 1) 获取未配置模型列表
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"created_models": 0,
"created_vendors": 0,
"skipped_models": []string{},
}})
return
}
// 2) 拉取上游 vendors 与 models
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
// vendor 失败不拦截
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
// 建立映射
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
}
}
// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
createdModels := 0
createdVendors := 0
updatedModels := 0
var skipped []string
var createdList []string
var updatedList []string
// 本地缓存:vendorName -> id
vendorIDCache := make(map[string]int)
for _, name := range missing {
up, ok := modelByName[name]
if !ok {
skipped = append(skipped, name)
continue
}
// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
var existing model.Model
if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
if existing.SyncOfficial == 0 {
skipped = append(skipped, name)
continue
}
}
// 确保 vendor 存在
vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 创建模型
mi := &model.Model{
ModelName: name,
Description: up.Description,
Icon: up.Icon,
Tags: up.Tags,
VendorID: vendorID,
Status: chooseStatus(up.Status, 1),
NameRule: up.NameRule,
}
if err := mi.Insert(); err == nil {
createdModels++
createdList = append(createdList, name)
} else {
skipped = append(skipped, name)
}
}
// 4) 处理可选覆盖(更新本地已有模型的差异字段)
if len(req.Overwrite) > 0 {
// vendorIDCache 已用于创建阶段,可复用
for _, ow := range req.Overwrite {
up, ok := modelByName[ow.ModelName]
if !ok {
continue
}
var local model.Model
if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
continue
}
// 跳过被禁用官方同步的模型
if local.SyncOfficial == 0 {
continue
}
// 映射 vendor
newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
// 应用字段覆盖(事务)
_ = model.DB.Transaction(func(tx *gorm.DB) error {
needUpdate := false
if containsField(ow.Fields, "description") {
local.Description = up.Description
needUpdate = true
}
if containsField(ow.Fields, "icon") {
local.Icon = up.Icon
needUpdate = true
}
if containsField(ow.Fields, "tags") {
local.Tags = up.Tags
needUpdate = true
}
if containsField(ow.Fields, "vendor") {
local.VendorID = newVendorID
needUpdate = true
}
if containsField(ow.Fields, "name_rule") {
local.NameRule = up.NameRule
needUpdate = true
}
if containsField(ow.Fields, "status") {
local.Status = chooseStatus(up.Status, local.Status)
needUpdate = true
}
if !needUpdate {
return nil
}
if err := tx.Save(&local).Error; err != nil {
return err
}
updatedModels++
updatedList = append(updatedList, ow.ModelName)
return nil
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"created_models": createdModels,
"created_vendors": createdVendors,
"updated_models": updatedModels,
"skipped_models": skipped,
"created_list": createdList,
"updated_list": updatedList,
"source": gin.H{
"locale": req.Locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}
func containsField(fields []string, key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, f := range fields {
if strings.ToLower(strings.TrimSpace(f)) == key {
return true
}
}
return false
}
func coalesce(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func chooseStatus(primary, fallback int) int {
if primary == 0 && fallback != 0 {
return fallback
}
if primary != 0 {
return primary
}
return 1
}
// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
func SyncUpstreamPreview(c *gin.Context) {
// 1) 拉取上游数据
timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
locale := c.Query("locale")
modelsURL, vendorsURL := getUpstreamURLs(locale)
var vendorsEnv upstreamEnvelope[upstreamVendor]
var modelsEnv upstreamEnvelope[upstreamModel]
var fetchErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
}()
go func() {
defer wg.Done()
if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
fetchErr = err
}
}()
wg.Wait()
if fetchErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
return
}
vendorByName := make(map[string]upstreamVendor)
for _, v := range vendorsEnv.Data {
if v.Name != "" {
vendorByName[v.Name] = v
}
}
modelByName := make(map[string]upstreamModel)
upstreamNames := make([]string, 0, len(modelsEnv.Data))
for _, m := range modelsEnv.Data {
if m.ModelName != "" {
modelByName[m.ModelName] = m
upstreamNames = append(upstreamNames, m.ModelName)
}
}
// 2) 本地已有模型
var locals []model.Model
if len(upstreamNames) > 0 {
_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
}
// 本地 vendor 名称映射
vendorIdSet := make(map[int]struct{})
for _, m := range locals {
if m.VendorID != 0 {
vendorIdSet[m.VendorID] = struct{}{}
}
}
vendorIDs := make([]int, 0, len(vendorIdSet))
for id := range vendorIdSet {
vendorIDs = append(vendorIDs, id)
}
idToVendorName := make(map[int]string)
if len(vendorIDs) > 0 {
var dbVendors []model.Vendor
_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
for _, v := range dbVendors {
idToVendorName[v.Id] = v.Name
}
}
// 3) 缺失且上游存在的模型
missingList, _ := model.GetMissingModels()
var missing []string
for _, name := range missingList {
if _, ok := modelByName[name]; ok {
missing = append(missing, name)
}
}
// 4) 计算冲突字段
type conflictField struct {
Field string `json:"field"`
Local interface{} `json:"local"`
Upstream interface{} `json:"upstream"`
}
type conflictItem struct {
ModelName string `json:"model_name"`
Fields []conflictField `json:"fields"`
}
var conflicts []conflictItem
for _, local := range locals {
up, ok := modelByName[local.ModelName]
if !ok {
continue
}
fields := make([]conflictField, 0, 6)
if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
}
if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
}
if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
}
// vendor 对比使用名称
localVendor := idToVendorName[local.VendorID]
if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
}
if local.NameRule != up.NameRule {
fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
}
if local.Status != chooseStatus(up.Status, local.Status) {
fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
}
if len(fields) > 0 {
conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"missing": missing,
"conflicts": conflicts,
"source": gin.H{
"locale": locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
}
+8 -8
View File
@@ -6,14 +6,14 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/system_setting"
"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"
)
@@ -45,7 +45,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
values.Set("code", code)
values.Set("grant_type", "authorization_code")
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
formData := values.Encode()
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
if err != nil {
@@ -69,7 +69,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
}
if oidcResponse.AccessToken == "" {
common.SysError("OIDC 获取 Token 失败,请检查设置!")
common.SysLog("OIDC 获取 Token 失败,请检查设置!")
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
}
@@ -85,7 +85,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
}
defer res2.Body.Close()
if res2.StatusCode != http.StatusOK {
common.SysError("OIDC 获取用户信息失败!请检查设置!")
common.SysLog("OIDC 获取用户信息失败!请检查设置!")
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
}
@@ -95,7 +95,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
return nil, err
}
if oidcUser.OpenID == "" || oidcUser.Email == "" {
common.SysError("OIDC 获取用户信息为空!请检查设置!")
common.SysLog("OIDC 获取用户信息为空!请检查设置!")
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
}
return &oidcUser, nil
+66 -14
View File
@@ -2,15 +2,17 @@ package controller
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting"
"one-api/setting/console_setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -35,8 +37,13 @@ func GetOptions(c *gin.Context) {
return
}
type OptionUpdateRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
func UpdateOption(c *gin.Context) {
var option model.Option
var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -45,6 +52,16 @@ func UpdateOption(c *gin.Context) {
})
return
}
switch option.Value.(type) {
case bool:
option.Value = common.Interface2String(option.Value.(bool))
case float64:
option.Value = common.Interface2String(option.Value.(float64))
case int:
option.Value = common.Interface2String(option.Value.(int))
default:
option.Value = fmt.Sprintf("%v", option.Value)
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
@@ -54,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{
@@ -104,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value)
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -112,8 +137,35 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "图片倍率设置失败: " + err.Error(),
})
return
}
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频倍率设置失败: " + err.Error(),
})
return
}
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频补全倍率设置失败: " + err.Error(),
})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value)
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -122,7 +174,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -131,7 +183,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -140,7 +192,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -149,7 +201,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -158,7 +210,7 @@ func UpdateOption(c *gin.Context) {
return
}
}
err = model.UpdateOption(option.Key, option.Value)
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
+497
View File
@@ -0,0 +1,497 @@
package controller
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credential = nil
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
var options []webauthnlib.RegistrationOption
if credential != nil {
descriptor := credential.ToWebAuthnCredential().Descriptor()
options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
}
creation, sessionData, err := wa.BeginRegistration(waUser, options...)
if err != nil {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": creation,
},
})
}
func PasskeyRegisterFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credentialRecord = nil
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
if passkeyCredential == nil {
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
return
}
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
})
}
func PasskeyDelete(c *gin.Context) {
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
})
}
func PasskeyStatus(c *gin.Context) {
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"enabled": false,
},
})
return
}
if err != nil {
common.ApiError(c, err)
return
}
data := gin.H{
"enabled": true,
"last_used_at": credential.LastUsedAt,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
func PasskeyLoginBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin()
if err != nil {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
},
})
}
func PasskeyLoginFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
if err != nil {
common.ApiError(c, err)
return
}
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
// 首先通过凭证ID查找用户
credential, err := model.GetPasskeyByCredentialID(rawID)
if err != nil {
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
}
// 通过凭证获取用户
user := &model.User{Id: credential.UserID}
if err := user.FillUserById(); err != nil {
return nil, fmt.Errorf("用户信息获取失败: %w", err)
}
if user.Status != common.UserStatusEnabled {
return nil, errors.New("该用户已被禁用")
}
if len(userHandle) > 0 {
userID, parseErr := strconv.Atoi(string(userHandle))
if parseErr != nil {
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
} else if userID != user.Id {
return nil, errors.New("用户句柄与凭证不匹配")
}
}
return passkeysvc.NewWebAuthnUser(user, credential), nil
}
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
if !ok {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
modelUser := userWrapper.ModelUser()
if modelUser == nil {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
if modelUser.Status != common.UserStatusEnabled {
common.ApiErrorMsg(c, "该用户已被禁用")
return
}
// 更新凭证信息
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
if updatedCredential == nil {
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
return
}
now := time.Now()
updatedCredential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
common.ApiError(c, err)
return
}
setupLogin(modelUser, c)
return
}
func AdminResetPasskey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorMsg(c, "无效的用户 ID")
return
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
common.ApiError(c, err)
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已重置",
})
}
func PasskeyVerifyBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
assertion, sessionData, err := wa.BeginLogin(waUser)
if err != nil {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
},
})
}
func PasskeyVerifyFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
})
}
func getSessionUser(c *gin.Context) (*model.User, error) {
session := sessions.Default(c)
idRaw := session.Get("id")
if idRaw == nil {
return nil, errors.New("未登录")
}
id, ok := idRaw.(int)
if !ok {
return nil, errors.New("无效的会话信息")
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled {
return nil, errors.New("该用户已被禁用")
}
return user, nil
}
+10 -33
View File
@@ -3,15 +3,14 @@ package controller
import (
"errors"
"fmt"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/setting"
"one-api/types"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
@@ -32,30 +31,8 @@ func Playground(c *gin.Context) {
return
}
playgroundRequest := &dto.PlayGroundRequest{}
err := common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
if playgroundRequest.Model == "" {
newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
c.Set("original_model", playgroundRequest.Model)
group := playgroundRequest.Group
userGroup := c.GetString("group")
if group == "" {
group = userGroup
} else {
if !setting.GroupInUserUsableGroups(group) && group != userGroup {
newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
return
}
c.Set("group", group)
}
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
modelName := c.GetString("original_model")
userId := c.GetInt("id")
@@ -73,12 +50,12 @@ func Playground(c *gin.Context) {
Group: group,
}
_ = middleware.SetupContextForToken(c, tempToken)
_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
_, newAPIError = getChannel(c, group, modelName, 0)
if newAPIError != nil {
return
}
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
Relay(c)
Relay(c, types.RelayFormatOpenAI)
}
+90
View File
@@ -0,0 +1,90 @@
package controller
import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
func GetPrefillGroups(c *gin.Context) {
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
}
// CreatePrefillGroup 创建新的预填组
func CreatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// UpdatePrefillGroup 更新预填组
func UpdatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// DeletePrefillGroup 删除预填组
func DeletePrefillGroup(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+11 -8
View File
@@ -1,9 +1,9 @@
package controller
import (
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
@@ -30,7 +30,7 @@ func GetPricing(c *gin.Context) {
}
}
usableGroup = setting.GetUserUsableGroups(group)
usableGroup = service.GetUserUsableGroups(group)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
@@ -39,10 +39,13 @@ func GetPricing(c *gin.Context) {
}
c.JSON(200, gin.H{
"success": true,
"data": pricing,
"group_ratio": groupRatio,
"usable_group": usableGroup,
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
})
}
+17 -16
View File
@@ -1,24 +1,25 @@
package controller
import (
"net/http"
"one-api/setting/ratio_setting"
"net/http"
"github.com/gin-gonic/gin"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}
+472 -406
View File
@@ -1,474 +1,540 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
const (
defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8
defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8
maxRatioConfigBytes = 10 << 20 // 10MB
floatEpsilon = 1e-9
)
func nearlyEqual(a, b float64) bool {
if a > b {
return a-b < floatEpsilon
}
return b-a < floatEpsilon
}
func valuesEqual(a, b interface{}) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
if aok && bok {
return nearlyEqual(af, bf)
}
return a == b
}
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
type upstreamResult struct {
Name string `json:"name"`
Data map[string]any `json:"data,omitempty"`
Err string `json:"err,omitempty"`
Name string `json:"name"`
Data map[string]any `json:"data,omitempty"`
Err string `json:"err,omitempty"`
}
func FetchUpstreamRatios(c *gin.Context) {
var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if req.Timeout <= 0 {
req.Timeout = defaultTimeoutSeconds
}
if req.Timeout <= 0 {
req.Timeout = defaultTimeoutSeconds
}
var upstreams []dto.UpstreamDTO
var upstreams []dto.UpstreamDTO
if len(req.Upstreams) > 0 {
for _, u := range req.Upstreams {
if strings.HasPrefix(u.BaseURL, "http") {
if u.Endpoint == "" {
u.Endpoint = defaultEndpoint
}
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
upstreams = append(upstreams, u)
}
}
} else if len(req.ChannelIDs) > 0 {
intIds := make([]int, 0, len(req.ChannelIDs))
for _, id64 := range req.ChannelIDs {
intIds = append(intIds, int(id64))
}
dbChannels, err := model.GetChannelsByIds(intIds)
if err != nil {
common.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
return
}
for _, ch := range dbChannels {
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
upstreams = append(upstreams, dto.UpstreamDTO{
ID: ch.Id,
Name: ch.Name,
BaseURL: strings.TrimRight(base, "/"),
Endpoint: "",
})
}
}
}
if len(req.Upstreams) > 0 {
for _, u := range req.Upstreams {
if strings.HasPrefix(u.BaseURL, "http") {
if u.Endpoint == "" {
u.Endpoint = defaultEndpoint
}
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
upstreams = append(upstreams, u)
}
}
} else if len(req.ChannelIDs) > 0 {
intIds := make([]int, 0, len(req.ChannelIDs))
for _, id64 := range req.ChannelIDs {
intIds = append(intIds, int(id64))
}
dbChannels, err := model.GetChannelsByIds(intIds)
if err != nil {
logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
return
}
for _, ch := range dbChannels {
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
upstreams = append(upstreams, dto.UpstreamDTO{
ID: ch.Id,
Name: ch.Name,
BaseURL: strings.TrimRight(base, "/"),
Endpoint: "",
})
}
}
}
if len(upstreams) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
return
}
if len(upstreams) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
return
}
var wg sync.WaitGroup
ch := make(chan upstreamResult, len(upstreams))
var wg sync.WaitGroup
ch := make(chan upstreamResult, len(upstreams))
sem := make(chan struct{}, maxConcurrentFetches)
sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
dialer := &net.Dialer{Timeout: 10 * time.Second}
transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
// 对 github.io 优先尝试 IPv4,失败则回退 IPv6
if strings.HasSuffix(host, "github.io") {
if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
return conn, nil
}
return dialer.DialContext(ctx, "tcp6", addr)
}
return dialer.DialContext(ctx, network, addr)
}
client := &http.Client{Transport: transport}
for _, chn := range upstreams {
wg.Add(1)
go func(chItem dto.UpstreamDTO) {
defer wg.Done()
for _, chn := range upstreams {
wg.Add(1)
go func(chItem dto.UpstreamDTO) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
sem <- struct{}{}
defer func() { <-sem }()
endpoint := chItem.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL := chItem.BaseURL + endpoint
endpoint := chItem.Endpoint
var fullURL string
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
fullURL = endpoint
} else {
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL = chItem.BaseURL + endpoint
}
uniqueName := chItem.Name
if chItem.ID != 0 {
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
}
uniqueName := chItem.Name
if chItem.ID != 0 {
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
defer cancel()
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
resp, err := client.Do(httpReq)
if err != nil {
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
var body struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
// 简单重试:最多 3 次,指数退避
var resp *http.Response
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
resp, lastErr = client.Do(httpReq)
if lastErr == nil {
break
}
time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
}
if lastErr != nil {
logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
// Content-Type 和响应体大小校验
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
}
limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
var body struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
if !body.Success {
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
return
}
if err := json.NewDecoder(limited).Decode(&body); err != nil {
logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
// 尝试按 type1 解析
var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
isType1 := false
for _, rt := range ratioTypes {
if _, ok := type1Data[rt]; ok {
isType1 = true
break
}
}
if isType1 {
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
return
}
}
if !body.Success {
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
return
}
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
var pricingItems []struct {
ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
CompletionRatio float64 `json:"completion_ratio"`
}
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
return
}
// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
modelRatioMap := make(map[string]float64)
completionRatioMap := make(map[string]float64)
modelPriceMap := make(map[string]float64)
// 尝试按 type1 解析
var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
isType1 := false
for _, rt := range ratioTypes {
if _, ok := type1Data[rt]; ok {
isType1 = true
break
}
}
if isType1 {
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
return
}
}
for _, item := range pricingItems {
if item.QuotaType == 1 {
modelPriceMap[item.ModelName] = item.ModelPrice
} else {
modelRatioMap[item.ModelName] = item.ModelRatio
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
completionRatioMap[item.ModelName] = item.CompletionRatio
}
}
// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
var pricingItems []struct {
ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
CompletionRatio float64 `json:"completion_ratio"`
}
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
return
}
converted := make(map[string]any)
modelRatioMap := make(map[string]float64)
completionRatioMap := make(map[string]float64)
modelPriceMap := make(map[string]float64)
if len(modelRatioMap) > 0 {
ratioAny := make(map[string]any, len(modelRatioMap))
for k, v := range modelRatioMap {
ratioAny[k] = v
}
converted["model_ratio"] = ratioAny
}
for _, item := range pricingItems {
if item.QuotaType == 1 {
modelPriceMap[item.ModelName] = item.ModelPrice
} else {
modelRatioMap[item.ModelName] = item.ModelRatio
// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
completionRatioMap[item.ModelName] = item.CompletionRatio
}
}
if len(completionRatioMap) > 0 {
compAny := make(map[string]any, len(completionRatioMap))
for k, v := range completionRatioMap {
compAny[k] = v
}
converted["completion_ratio"] = compAny
}
converted := make(map[string]any)
if len(modelPriceMap) > 0 {
priceAny := make(map[string]any, len(modelPriceMap))
for k, v := range modelPriceMap {
priceAny[k] = v
}
converted["model_price"] = priceAny
}
if len(modelRatioMap) > 0 {
ratioAny := make(map[string]any, len(modelRatioMap))
for k, v := range modelRatioMap {
ratioAny[k] = v
}
converted["model_ratio"] = ratioAny
}
ch <- upstreamResult{Name: uniqueName, Data: converted}
}(chn)
}
if len(completionRatioMap) > 0 {
compAny := make(map[string]any, len(completionRatioMap))
for k, v := range completionRatioMap {
compAny[k] = v
}
converted["completion_ratio"] = compAny
}
wg.Wait()
close(ch)
if len(modelPriceMap) > 0 {
priceAny := make(map[string]any, len(modelPriceMap))
for k, v := range modelPriceMap {
priceAny[k] = v
}
converted["model_price"] = priceAny
}
localData := ratio_setting.GetExposedData()
ch <- upstreamResult{Name: uniqueName, Data: converted}
}(chn)
}
var testResults []dto.TestResult
var successfulChannels []struct {
name string
data map[string]any
}
wg.Wait()
close(ch)
for r := range ch {
if r.Err != "" {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "error",
Error: r.Err,
})
} else {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "success",
})
successfulChannels = append(successfulChannels, struct {
name string
data map[string]any
}{name: r.Name, data: r.Data})
}
}
localData := ratio_setting.GetExposedData()
differences := buildDifferences(localData, successfulChannels)
var testResults []dto.TestResult
var successfulChannels []struct {
name string
data map[string]any
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"differences": differences,
"test_results": testResults,
},
})
for r := range ch {
if r.Err != "" {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "error",
Error: r.Err,
})
} else {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "success",
})
successfulChannels = append(successfulChannels, struct {
name string
data map[string]any
}{name: r.Name, data: r.Data})
}
}
differences := buildDifferences(localData, successfulChannels)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"differences": differences,
"test_results": testResults,
},
})
}
func buildDifferences(localData map[string]any, successfulChannels []struct {
name string
data map[string]any
name string
data map[string]any
}) map[string]map[string]dto.DifferenceItem {
differences := make(map[string]map[string]dto.DifferenceItem)
differences := make(map[string]map[string]dto.DifferenceItem)
allModels := make(map[string]struct{})
for _, ratioType := range ratioTypes {
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
for modelName := range localRatio {
allModels[modelName] = struct{}{}
}
}
}
}
for _, channel := range successfulChannels {
for _, ratioType := range ratioTypes {
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
for modelName := range upstreamRatio {
allModels[modelName] = struct{}{}
}
}
}
}
allModels := make(map[string]struct{})
confidenceMap := make(map[string]map[string]bool)
// 预处理阶段:检查pricing接口的可信度
for _, channel := range successfulChannels {
confidenceMap[channel.name] = make(map[string]bool)
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
if hasModelRatio && hasCompletionRatio {
// 遍历所有模型,检查是否满足不可信条件
for modelName := range allModels {
// 默认为可信
confidenceMap[channel.name][modelName] = true
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
if modelRatioVal, ok := modelRatios[modelName]; ok {
if completionRatioVal, ok := completionRatios[modelName]; ok {
// 转换为float64进行比较
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
confidenceMap[channel.name][modelName] = false
}
}
}
}
}
}
} else {
// 如果不是从pricing接口获取的数据,则全部标记为可信
for modelName := range allModels {
confidenceMap[channel.name][modelName] = true
}
}
}
for _, ratioType := range ratioTypes {
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
for modelName := range localRatio {
allModels[modelName] = struct{}{}
}
}
}
}
for modelName := range allModels {
for _, ratioType := range ratioTypes {
var localValue interface{} = nil
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
if val, exists := localRatio[modelName]; exists {
localValue = val
}
}
}
for _, channel := range successfulChannels {
for _, ratioType := range ratioTypes {
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
for modelName := range upstreamRatio {
allModels[modelName] = struct{}{}
}
}
}
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
confidenceMap := make(map[string]map[string]bool)
for _, channel := range successfulChannels {
var upstreamValue interface{} = nil
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
if val, exists := upstreamRatio[modelName]; exists {
upstreamValue = val
hasUpstreamValue = true
if localValue != nil && localValue != val {
hasDifference = true
} else if localValue == val {
upstreamValue = "same"
}
}
}
if upstreamValue == nil && localValue == nil {
upstreamValue = "same"
}
if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
hasDifference = true
}
upstreamValues[channel.name] = upstreamValue
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
}
// 预处理阶段:检查pricing接口的可信度
for _, channel := range successfulChannels {
confidenceMap[channel.name] = make(map[string]bool)
shouldInclude := false
if localValue != nil {
if hasDifference {
shouldInclude = true
}
} else {
if hasUpstreamValue {
shouldInclude = true
}
}
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
if shouldInclude {
if differences[modelName] == nil {
differences[modelName] = make(map[string]dto.DifferenceItem)
}
differences[modelName][ratioType] = dto.DifferenceItem{
Current: localValue,
Upstreams: upstreamValues,
Confidence: confidenceValues,
}
}
}
}
if hasModelRatio && hasCompletionRatio {
// 遍历所有模型,检查是否满足不可信条件
for modelName := range allModels {
// 默认为可信
confidenceMap[channel.name][modelName] = true
channelHasDiff := make(map[string]bool)
for _, ratioMap := range differences {
for _, item := range ratioMap {
for chName, val := range item.Upstreams {
if val != nil && val != "same" {
channelHasDiff[chName] = true
}
}
}
}
// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
if modelRatioVal, ok := modelRatios[modelName]; ok {
if completionRatioVal, ok := completionRatios[modelName]; ok {
// 转换为float64进行比较
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
confidenceMap[channel.name][modelName] = false
}
}
}
}
}
}
} else {
// 如果不是从pricing接口获取的数据,则全部标记为可信
for modelName := range allModels {
confidenceMap[channel.name][modelName] = true
}
}
}
for modelName, ratioMap := range differences {
for ratioType, item := range ratioMap {
for chName := range item.Upstreams {
if !channelHasDiff[chName] {
delete(item.Upstreams, chName)
delete(item.Confidence, chName)
}
}
for modelName := range allModels {
for _, ratioType := range ratioTypes {
var localValue interface{} = nil
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
if val, exists := localRatio[modelName]; exists {
localValue = val
}
}
}
allSame := true
for _, v := range item.Upstreams {
if v != "same" {
allSame = false
break
}
}
if len(item.Upstreams) == 0 || allSame {
delete(ratioMap, ratioType)
} else {
differences[modelName][ratioType] = item
}
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
if len(ratioMap) == 0 {
delete(differences, modelName)
}
}
for _, channel := range successfulChannels {
var upstreamValue interface{} = nil
return differences
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
if val, exists := upstreamRatio[modelName]; exists {
upstreamValue = val
hasUpstreamValue = true
if localValue != nil && !valuesEqual(localValue, val) {
hasDifference = true
} else if valuesEqual(localValue, val) {
upstreamValue = "same"
}
}
}
if upstreamValue == nil && localValue == nil {
upstreamValue = "same"
}
if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
hasDifference = true
}
upstreamValues[channel.name] = upstreamValue
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
}
shouldInclude := false
if localValue != nil {
if hasDifference {
shouldInclude = true
}
} else {
if hasUpstreamValue {
shouldInclude = true
}
}
if shouldInclude {
if differences[modelName] == nil {
differences[modelName] = make(map[string]dto.DifferenceItem)
}
differences[modelName][ratioType] = dto.DifferenceItem{
Current: localValue,
Upstreams: upstreamValues,
Confidence: confidenceValues,
}
}
}
}
channelHasDiff := make(map[string]bool)
for _, ratioMap := range differences {
for _, item := range ratioMap {
for chName, val := range item.Upstreams {
if val != nil && val != "same" {
channelHasDiff[chName] = true
}
}
}
}
for modelName, ratioMap := range differences {
for ratioType, item := range ratioMap {
for chName := range item.Upstreams {
if !channelHasDiff[chName] {
delete(item.Upstreams, chName)
delete(item.Confidence, chName)
}
}
allSame := true
for _, v := range item.Upstreams {
if v != "same" {
allSame = false
break
}
}
if len(item.Upstreams) == 0 || allSame {
delete(ratioMap, ratioType)
} else {
differences[modelName][ratioType] = item
}
}
if len(ratioMap) == 0 {
delete(differences, modelName)
}
}
return differences
}
func GetSyncableChannels(c *gin.Context) {
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
var syncableChannels []dto.SyncableChannel
for _, channel := range channels {
if channel.GetBaseURL() != "" {
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: channel.Id,
Name: channel.Name,
BaseURL: channel.GetBaseURL(),
Status: channel.Status,
})
}
}
var syncableChannels []dto.SyncableChannel
for _, channel := range channels {
if channel.GetBaseURL() != "" {
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: channel.Id,
Name: channel.Name,
BaseURL: channel.GetBaseURL(),
Status: channel.Status,
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": syncableChannels,
})
}
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: -100,
Name: "官方倍率预设",
BaseURL: "https://basellm.github.io",
Status: 1,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": syncableChannels,
})
}
+3 -2
View File
@@ -3,11 +3,12 @@ package controller
import (
"errors"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
+223 -221
View File
@@ -2,122 +2,199 @@ package controller
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"net/http"
"one-api/common"
"one-api/constant"
constant2 "one-api/constant"
"one-api/dto"
"one-api/middleware"
"one-api/model"
"one-api/relay"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"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"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
var err *types.NewAPIError
switch relayMode {
switch info.RelayMode {
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
err = relay.ImageHelper(c)
err = relay.ImageHelper(c, info)
case relayconstant.RelayModeAudioSpeech:
fallthrough
case relayconstant.RelayModeAudioTranslation:
fallthrough
case relayconstant.RelayModeAudioTranscription:
err = relay.AudioHelper(c)
err = relay.AudioHelper(c, info)
case relayconstant.RelayModeRerank:
err = relay.RerankHelper(c, relayMode)
err = relay.RerankHelper(c, info)
case relayconstant.RelayModeEmbeddings:
err = relay.EmbeddingHelper(c)
err = relay.EmbeddingHelper(c, info)
case relayconstant.RelayModeResponses:
err = relay.ResponsesHelper(c)
case relayconstant.RelayModeGemini:
err = relay.GeminiHelper(c)
err = relay.ResponsesHelper(c, info)
default:
err = relay.TextHelper(c)
err = relay.TextHelper(c, info)
}
if constant2.ErrorLogEnabled && err != nil && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
userGroup := c.GetString("group")
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
other["admin_info"] = adminInfo
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
}
return err
}
func Relay(c *gin.Context) {
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
var err *types.NewAPIError
if strings.Contains(c.Request.URL.Path, "embed") {
err = relay.GeminiEmbeddingHandler(c, info)
} else {
err = relay.GeminiHelper(c, info)
}
return err
}
func Relay(c *gin.Context, relayFormat types.RelayFormat) {
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
var (
newAPIError *types.NewAPIError
ws *websocket.Conn
)
if relayFormat == types.RelayFormatOpenAIRealtime {
var err error
ws, err = upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
return
}
defer ws.Close()
}
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
helper.WssError(c, ws, newAPIError.ToOpenAIError())
case types.RelayFormatClaude:
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
}
}()
request, err := helper.GetAndValidateRequest(c, relayFormat)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
return
}
relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
return
}
meta := request.GetTokenCountMeta()
if setting.ShouldCheckPromptSensitive() {
contains, words := service.CheckSensitiveText(meta.CombineText)
if contains {
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
return
}
}
tokens, err := service.CountRequestToken(c, meta, relayInfo)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
return
}
relayInfo.SetPromptTokens(tokens)
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
return
}
// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
if priceData.FreeModel {
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
} else {
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
if newAPIError != nil {
return
}
}
defer func() {
// Only return quota if downstream failed and quota was actually pre-consumed
if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
service.ReturnPreConsumedQuota(c, relayInfo)
}
}()
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
logger.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = relayRequest(c, relayMode, channel)
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
if newAPIError == nil {
return // 成功处理请求,直接返回
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
newAPIError = relay.WssHelper(c, relayInfo)
case types.RelayFormatClaude:
newAPIError = relay.ClaudeHelper(c, relayInfo)
case types.RelayFormatGemini:
newAPIError = geminiRelayHandler(c, relayInfo)
default:
newAPIError = relayHandler(c, relayInfo)
}
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if newAPIError == nil {
return
}
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
logger.LogInfo(c, retryLogStr)
}
}
@@ -128,122 +205,6 @@ var upgrader = websocket.Upgrader{
},
}
func WssRelay(c *gin.Context) {
// 将 HTTP 连接升级为 WebSocket 连接
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
defer ws.Close()
if err != nil {
helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
return
}
relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = wssRequest(c, ws, relayMode, channel)
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
helper.WssError(c, ws, newAPIError.ToOpenAIError())
}
}
func RelayClaude(c *gin.Context) {
//relayMode := constant.Path2RelayMode(c.Request.URL.Path)
requestId := c.GetString(common.RequestIdKey)
group := c.GetString("group")
originalModel := c.GetString("original_model")
var newAPIError *types.NewAPIError
for i := 0; i <= common.RetryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = err
break
}
newAPIError = claudeRequest(c, channel)
if newAPIError == nil {
return // 成功处理请求,直接返回
}
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
}
if newAPIError != nil {
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"type": "error",
"error": newAPIError.ToClaudeError(),
})
}
}
func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relayHandler(c, relayMode)
}
func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relay.WssHelper(c, ws)
}
func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError {
addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return relay.ClaudeHelper(c)
}
func addUsedChannel(c *gin.Context, channelId int) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
@@ -264,12 +225,12 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt,
}, nil
}
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry: %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
@@ -308,10 +269,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return true
}
if openaiErr.StatusCode == http.StatusBadRequest {
channelType := c.GetInt("channel_type")
if channelType == constant.ChannelTypeAnthropic {
return true
}
return false
}
if openaiErr.StatusCode == 408 {
@@ -325,44 +282,86 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
service.DisableChannel(channelError, err.Error())
gopool.Go(func() {
service.DisableChannel(channelError, err.Error())
})
}
if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
// 保存错误日志到mysql中
userId := c.GetInt("id")
tokenName := c.GetString("token_name")
modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id")
userGroup := c.GetString("group")
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
if c.Request != nil && c.Request.URL != nil {
other["request_path"] = c.Request.URL.Path
}
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
other["admin_info"] = adminInfo
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
}
}
func RelayMidjourney(c *gin.Context) {
relayMode := c.GetInt("relay_mode")
var err *dto.MidjourneyResponse
switch relayMode {
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"description": fmt.Sprintf("failed to generate relay info: %s", err.Error()),
"type": "upstream_error",
"code": 4,
})
return
}
var mjErr *dto.MidjourneyResponse
switch relayInfo.RelayMode {
case relayconstant.RelayModeMidjourneyNotify:
err = relay.RelayMidjourneyNotify(c)
mjErr = relay.RelayMidjourneyNotify(c)
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
err = relay.RelayMidjourneyTask(c, relayMode)
mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode)
case relayconstant.RelayModeMidjourneyTaskImageSeed:
err = relay.RelayMidjourneyTaskImageSeed(c)
mjErr = relay.RelayMidjourneyTaskImageSeed(c)
case relayconstant.RelayModeSwapFace:
err = relay.RelaySwapFace(c)
mjErr = relay.RelaySwapFace(c, relayInfo)
default:
err = relay.RelayMidjourneySubmit(c, relayMode)
mjErr = relay.RelayMidjourneySubmit(c, relayInfo)
}
//err = relayMidjourneySubmit(c, relayMode)
log.Println(err)
if err != nil {
log.Println(mjErr)
if mjErr != nil {
statusCode := http.StatusBadRequest
if err.Code == 30 {
err.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
if mjErr.Code == 30 {
mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
statusCode = http.StatusTooManyRequests
}
c.JSON(statusCode, gin.H{
"description": fmt.Sprintf("%s %s", err.Description, err.Result),
"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
"type": "upstream_error",
"code": err.Code,
"code": mjErr.Code,
})
channelId := c.GetInt("channel_id")
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", err.Description, err.Result)))
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
}
}
@@ -393,18 +392,21 @@ func RelayNotFound(c *gin.Context) {
func RelayTask(c *gin.Context) {
retryTimes := common.RetryTimes
channelId := c.GetInt("channel_id")
relayMode := c.GetInt("relay_mode")
group := c.GetString("group")
originalModel := c.GetString("original_model")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
taskErr := taskRelayHandler(c, relayMode)
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
return
}
taskErr := taskRelayHandler(c, relayInfo)
if taskErr == nil {
retryTimes = 0
}
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
channel, newAPIError := getChannel(c, group, originalModel, i)
if newAPIError != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
@@ -412,17 +414,17 @@ func RelayTask(c *gin.Context) {
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayMode)
taskErr = taskRelayHandler(c, relayInfo)
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
common.LogInfo(c, retryLogStr)
logger.LogInfo(c, retryLogStr)
}
if taskErr != nil {
if taskErr.StatusCode == http.StatusTooManyRequests {
@@ -432,13 +434,13 @@ func RelayTask(c *gin.Context) {
}
}
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
var err *dto.TaskError
switch relayMode {
switch relayInfo.RelayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
err = relay.RelayTaskFetch(c, relayMode)
err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
default:
err = relay.RelayTaskSubmit(c, relayMode)
err = relay.RelayTaskSubmit(c, relayInfo)
}
return err
}
+314
View File
@@ -0,0 +1,314 @@
package controller
import (
"fmt"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Code string `json:"code,omitempty"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
// 这里只是验证 Passkey 验证流程是否已经完成
// 实际上,前端应该先调用这两个接口,然后再调用本接口
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
},
})
}
// GetVerificationStatus 获取验证状态
func GetVerificationStatus(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: true,
ExpiresAt: verifiedAt + SecureVerificationTimeout,
},
})
}
// CheckSecureVerification 检查是否已通过安全验证
// 返回 true 表示验证有效,false 表示需要重新验证
func CheckSecureVerification(c *gin.Context) bool {
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
return false
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
return false
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
return false
}
return true
}
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
_ = session.Save()
}
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
// 整合了 begin 和 finish 流程
func PasskeyVerifyForSecure(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
credential, err := model.GetPasskeyByUserID(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
// 验证成功,设置 session
PasskeyVerifyAndSetSession(c)
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"verified": true,
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
},
})
}
+16 -15
View File
@@ -1,12 +1,13 @@
package controller
import (
"github.com/gin-gonic/gin"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/setting/operation_setting"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
)
type Setup struct {
@@ -53,7 +54,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +67,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +78,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +86,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +94,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +104,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +121,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +136,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +145,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +161,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
+21 -19
View File
@@ -7,15 +7,17 @@ import (
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"sort"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
@@ -54,9 +56,9 @@ func UpdateTaskBulk() {
"progress": "100%",
})
if err != nil {
common.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
} else {
common.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
@@ -86,14 +88,14 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM
for channelId, taskIds := range taskChannelM {
err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
if err != nil {
common.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
}
}
return nil
}
func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
}
@@ -106,7 +108,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
"progress": "100%",
})
if err != nil {
common.SysError(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
common.SysLog(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
}
return err
}
@@ -118,23 +120,23 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
"ids": taskIds,
})
if err != nil {
common.SysError(fmt.Sprintf("Get Task Do req error: %v", err))
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
return err
}
if resp.StatusCode != http.StatusOK {
common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.SysError(fmt.Sprintf("Get Task parse body error: %v", err))
common.SysLog(fmt.Sprintf("Get Task parse body error: %v", err))
return err
}
var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
err = json.Unmarshal(responseBody, &responseItems)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
return err
}
if !responseItems.IsSuccess() {
@@ -154,19 +156,19 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
common.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
task.Progress = "100%"
//err = model.CacheUpdateUserQuota(task.UserId) ?
if err != nil {
common.LogError(ctx, "error update user quota cache: "+err.Error())
logger.LogError(ctx, "error update user quota cache: "+err.Error())
} else {
quota := task.Quota
if quota != 0 {
err = model.IncreaseUserQuota(task.UserId, quota, false)
if err != nil {
common.LogError(ctx, "fail to increase user quota: "+err.Error())
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, common.LogQuota(quota))
logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
@@ -178,7 +180,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
err = task.Update()
if err != nil {
common.SysError("UpdateMidjourneyTask task error: " + err.Error())
common.SysLog("UpdateMidjourneyTask task error: " + err.Error())
}
}
return nil
+182 -23
View File
@@ -5,27 +5,30 @@ import (
"encoding/json"
"fmt"
"io"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
}
return nil
}
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
}
@@ -37,7 +40,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
"progress": "100%",
})
if errUpdate != nil {
common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
}
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
@@ -45,9 +48,15 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
if adaptor == nil {
return fmt.Errorf("video adaptor not found")
}
info := &relaycommon.RelayInfo{}
info.ChannelMeta = &relaycommon.ChannelMeta{
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
}
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
for _, taskId := range taskIds {
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
}
return nil
@@ -61,7 +70,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task := taskM[taskId]
if task == nil {
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
return fmt.Errorf("task %s not found", taskId)
}
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
@@ -80,26 +89,39 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
taskResult.Url = t.FailReason
taskResult.Progress = t.Progress
taskResult.Reason = t.FailReason
task.Data = t.Data
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
} else {
task.Data = responseBody
task.Data = redactVideoResponseBody(responseBody)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
now := time.Now().Unix()
if taskResult.Status == "" {
return fmt.Errorf("task %s status is empty", taskId)
//return fmt.Errorf("task %s status is empty", taskId)
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
}
// 记录原本的状态,防止重复退款
shouldRefund := false
quota := task.Quota
preStatus := task.Status
task.Status = model.TaskStatus(taskResult.Status)
switch taskResult.Status {
case model.TaskStatusSubmitted:
@@ -116,22 +138,115 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Url
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
task.FailReason = taskResult.Url
}
// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
if taskResult.TotalTokens > 0 {
// 获取模型名称
var taskData map[string]interface{}
if err := json.Unmarshal(task.Data, &taskData); err == nil {
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
// 获取模型价格和倍率
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
// 只有配置了倍率(非固定价格)时才按 token 重新计费
if hasRatioSetting && modelRatio > 0 {
// 获取用户和组的倍率信息
group := task.Group
if group == "" {
user, err := model.GetUserById(task.UserId, false)
if err == nil {
group = user.Group
}
}
if group != "" {
groupRatio := ratio_setting.GetGroupRatio(group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
// 计算差额
preConsumedQuota := task.Quota
quotaDelta := actualQuota - preConsumedQuota
if quotaDelta > 0 {
// 需要补扣费
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%stokens%d",
task.TaskID,
logger.LogQuota(quotaDelta),
logger.LogQuota(actualQuota),
logger.LogQuota(preConsumedQuota),
taskResult.TotalTokens,
))
if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil {
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
} else {
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
task.Quota = actualQuota // 更新任务记录的实际扣费额度
// 记录消费日志
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
} else if quotaDelta < 0 {
// 需要退还多扣的费用
refundQuota := -quotaDelta
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%stokens%d",
task.TaskID,
logger.LogQuota(refundQuota),
logger.LogQuota(actualQuota),
logger.LogQuota(preConsumedQuota),
taskResult.TotalTokens,
))
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
} else {
task.Quota = actualQuota // 更新任务记录的实际扣费额度
// 记录退款日志
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
modelRatio, finalGroupRatio, taskResult.TotalTokens,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
} else {
// quotaDelta == 0, 预扣费刚好准确
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%stokens%d",
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
}
}
}
}
}
}
case model.TaskStatusFailure:
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
task.Status = model.TaskStatusFailure
task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Reason
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
quota := task.Quota
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
taskResult.Progress = "100%"
if quota != 0 {
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
common.LogError(ctx, "Failed to increase user quota: "+err.Error())
if preStatus != model.TaskStatusFailure {
shouldRefund = true
} else {
logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
default:
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
@@ -140,8 +255,52 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.Progress = taskResult.Progress
}
if err := task.Update(); err != nil {
common.SysError("UpdateVideoTask task error: " + err.Error())
common.SysLog("UpdateVideoTask task error: " + err.Error())
shouldRefund = false
}
if shouldRefund {
// 任务失败且之前状态不是失败才退还额度,防止重复退还
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
return nil
}
func redactVideoResponseBody(body []byte) []byte {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return body
}
resp, _ := m["response"].(map[string]any)
if resp != nil {
delete(resp, "bytesBase64Encoded")
if v, ok := resp["video"].(string); ok {
resp["video"] = truncateBase64(v)
}
if vs, ok := resp["videos"].([]any); ok {
for i := range vs {
if vm, ok := vs[i].(map[string]any); ok {
delete(vm, "bytesBase64Encoded")
}
}
}
}
b, err := json.Marshal(m)
if err != nil {
return body
}
return b
}
func truncateBase64(s string) string {
const maxKeep = 256
if len(s) <= maxKeep {
return s
}
return s[:maxKeep] + "..."
}
+4 -3
View File
@@ -6,10 +6,11 @@ import (
"encoding/hex"
"io"
"net/http"
"one-api/common"
"one-api/model"
"sort"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -65,7 +66,7 @@ func TelegramBind(c *gin.Context) {
return
}
c.Redirect(302, "/setting")
c.Redirect(302, "/console/personal")
}
func TelegramLogin(c *gin.Context) {
+56 -3
View File
@@ -2,9 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
@@ -82,6 +84,57 @@ func GetTokenStatus(c *gin.Context) {
})
}
func GetTokenUsage(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "No Authorization header",
})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "Invalid Bearer token",
})
return
}
tokenKey := parts[1]
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
c.JSON(http.StatusOK, gin.H{
"code": true,
"message": "ok",
"data": gin.H{
"object": "token_usage",
"name": token.Name,
"total_granted": token.RemainQuota + token.UsedQuota,
"total_used": token.UsedQuota,
"total_available": token.RemainQuota,
"unlimited_quota": token.UnlimitedQuota,
"model_limits": token.GetModelLimitsMap(),
"model_limits_enabled": token.ModelLimitsEnabled,
"expires_at": expiredAt,
},
})
}
func AddToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
@@ -102,7 +155,7 @@ func AddToken(c *gin.Context) {
"success": false,
"message": "生成令牌失败",
})
common.SysError("failed to generate token key: " + err.Error())
common.SysLog("failed to generate token key: " + err.Error())
return
}
cleanToken := model.Token{
+152 -25
View File
@@ -4,20 +4,64 @@ import (
"fmt"
"log"
"net/url"
"one-api/common"
"one-api/model"
"one-api/service"
"one-api/setting"
"strconv"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/Calcium-Ion/go-epay/epay"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/shopspring/decimal"
)
func GetTopUpInfo(c *gin.Context) {
// 获取支付方式
payMethods := operation_setting.PayMethods
// 如果启用了 Stripe 支付,添加到支付方法列表
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
// 检查是否已经包含 Stripe
hasStripe := false
for _, method := range payMethods {
if method["type"] == "stripe" {
hasStripe = true
break
}
}
if !hasStripe {
stripeMethod := map[string]string{
"name": "Stripe",
"type": "stripe",
"color": "rgba(var(--semi-purple-5), 1)",
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
}
payMethods = append(payMethods, stripeMethod)
}
}
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
}
common.ApiSuccess(c, data)
}
type EpayRequest struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
@@ -30,13 +74,13 @@ type AmountRequest struct {
}
func GetEpayClient() *epay.Client {
if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
return nil
}
withUrl, err := epay.NewClient(&epay.Config{
PartnerID: setting.EpayId,
Key: setting.EpayKey,
}, setting.PayAddress)
PartnerID: operation_setting.EpayId,
Key: operation_setting.EpayKey,
}, operation_setting.PayAddress)
if err != nil {
return nil
}
@@ -45,8 +89,9 @@ func GetEpayClient() *epay.Client {
func getPayMoney(amount int64, group string) float64 {
dAmount := decimal.NewFromInt(amount)
if !common.DisplayInCurrencyEnabled {
// 充值金额以“展示类型”为准:
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
dAmount = dAmount.Div(dQuotaPerUnit)
}
@@ -57,16 +102,24 @@ func getPayMoney(amount int64, group string) float64 {
}
dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
dPrice := decimal.NewFromFloat(setting.Price)
dPrice := decimal.NewFromFloat(operation_setting.Price)
// apply optional preset discount by the original request amount (if configured), default 1.0
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
if ds > 0 {
discount = ds
}
}
dDiscount := decimal.NewFromFloat(discount)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio)
payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
return payMoney.InexactFloat64()
}
func getMinTopup() int64 {
minTopup := setting.MinTopUp
if !common.DisplayInCurrencyEnabled {
minTopup := operation_setting.MinTopUp
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
@@ -98,13 +151,13 @@ func RequestEpay(c *gin.Context) {
return
}
if !setting.ContainsPayMethod(req.PaymentMethod) {
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
@@ -127,18 +180,19 @@ func RequestEpay(c *gin.Context) {
return
}
amount := req.Amount
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dAmount := decimal.NewFromInt(int64(amount))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
amount = dAmount.Div(dQuotaPerUnit).IntPart()
}
topUp := &model.TopUp{
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
CreateTime: time.Now().Unix(),
Status: "pending",
UserId: id,
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
}
err = topUp.Insert()
if err != nil {
@@ -186,8 +240,8 @@ func EpayNotify(c *gin.Context) {
_, err := c.Writer.Write([]byte("fail"))
if err != nil {
log.Println("易支付回调写入失败")
return
}
return
}
verifyInfo, err := client.Verify(params)
if err == nil && verifyInfo.VerifyStatus {
@@ -231,7 +285,7 @@ func EpayNotify(c *gin.Context) {
return
}
log.Printf("易支付回调更新用户成功 %v", topUp)
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", common.LogQuota(quotaToAdd), topUp.Money))
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
}
} else {
log.Printf("易支付异常回调: %v", verifyInfo)
@@ -263,3 +317,76 @@ func RequestAmount(c *gin.Context) {
}
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
}
func GetUserTopUps(c *gin.Context) {
userId := c.GetInt("id")
pageInfo := common.GetPageQuery(c)
keyword := c.Query("keyword")
var (
topups []*model.TopUp
total int64
err error
)
if keyword != "" {
topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo)
} else {
topups, total, err = model.GetUserTopUps(userId, pageInfo)
}
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(topups)
common.ApiSuccess(c, pageInfo)
}
// GetAllTopUps 管理员获取全平台充值记录
func GetAllTopUps(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
keyword := c.Query("keyword")
var (
topups []*model.TopUp
total int64
err error
)
if keyword != "" {
topups, total, err = model.SearchAllTopUps(keyword, pageInfo)
} else {
topups, total, err = model.GetAllTopUps(pageInfo)
}
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(topups)
common.ApiSuccess(c, pageInfo)
}
type AdminCompleteTopupRequest struct {
TradeNo string `json:"trade_no"`
}
// AdminCompleteTopUp 管理员补单接口
func AdminCompleteTopUp(c *gin.Context) {
var req AdminCompleteTopupRequest
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
// 订单级互斥,防止并发补单
LockOrder(req.TradeNo)
defer UnlockOrder(req.TradeNo)
if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+461
View File
@@ -0,0 +1,461 @@
package controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"time"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
)
const (
PaymentMethodCreem = "creem"
CreemSignatureHeader = "creem-signature"
)
var creemAdaptor = &CreemAdaptor{}
// 生成HMAC-SHA256签名
func generateCreemSignature(payload string, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
// 验证Creem webhook签名
func verifyCreemSignature(payload string, signature string, secret string) bool {
if secret == "" {
log.Printf("Creem webhook secret not set")
if setting.CreemTestMode {
log.Printf("Skip Creem webhook sign verify in test mode")
return true
}
return false
}
expectedSignature := generateCreemSignature(payload, secret)
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
type CreemPayRequest struct {
ProductId string `json:"product_id"`
PaymentMethod string `json:"payment_method"`
}
type CreemProduct struct {
ProductId string `json:"productId"`
Name string `json:"name"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Quota int64 `json:"quota"`
}
type CreemAdaptor struct {
}
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
if req.PaymentMethod != PaymentMethodCreem {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.ProductId == "" {
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
return
}
// 解析产品列表
var products []CreemProduct
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
if err != nil {
log.Println("解析Creem产品列表失败", err)
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
return
}
// 查找对应的产品
var selectedProduct *CreemProduct
for _, product := range products {
if product.ProductId == req.ProductId {
selectedProduct = &product
break
}
}
if selectedProduct == nil {
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
return
}
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
// 生成唯一的订单引用ID
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "ref_" + common.Sha1([]byte(reference))
// 先创建订单记录,使用产品配置的金额和充值额度
topUp := &model.TopUp{
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
log.Printf("创建Creem订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// 创建支付链接,传入用户邮箱
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
if err != nil {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
c.JSON(200, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
"order_id": referenceId,
},
})
}
func RequestCreemPay(c *gin.Context) {
var req CreemPayRequest
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
return
}
// 打印body内容
log.Printf("creem pay request body: %s", string(bodyBytes))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
err = c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
creemAdaptor.RequestPay(c, &req)
}
// 新的Creem Webhook结构体,匹配实际的webhook数据格式
type CreemWebhookEvent struct {
Id string `json:"id"`
EventType string `json:"eventType"`
CreatedAt int64 `json:"created_at"`
Object struct {
Id string `json:"id"`
Object string `json:"object"`
RequestId string `json:"request_id"`
Order struct {
Object string `json:"object"`
Id string `json:"id"`
Customer string `json:"customer"`
Product string `json:"product"`
Amount int `json:"amount"`
Currency string `json:"currency"`
SubTotal int `json:"sub_total"`
TaxAmount int `json:"tax_amount"`
AmountDue int `json:"amount_due"`
AmountPaid int `json:"amount_paid"`
Status string `json:"status"`
Type string `json:"type"`
Transaction string `json:"transaction"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"order"`
Product struct {
Id string `json:"id"`
Object string `json:"object"`
Name string `json:"name"`
Description string `json:"description"`
Price int `json:"price"`
Currency string `json:"currency"`
BillingType string `json:"billing_type"`
BillingPeriod string `json:"billing_period"`
Status string `json:"status"`
TaxMode string `json:"tax_mode"`
TaxCategory string `json:"tax_category"`
DefaultSuccessUrl *string `json:"default_success_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"product"`
Units int `json:"units"`
Customer struct {
Id string `json:"id"`
Object string `json:"object"`
Email string `json:"email"`
Name string `json:"name"`
Country string `json:"country"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"customer"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
Mode string `json:"mode"`
} `json:"object"`
}
// 保留旧的结构体作为兼容
type CreemWebhookData struct {
Type string `json:"type"`
Data struct {
RequestId string `json:"request_id"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
} `json:"data"`
}
func CreemWebhook(c *gin.Context) {
// 读取body内容用于打印,同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("读取Creem Webhook请求body失败: %v", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 获取签名头
signature := c.GetHeader(CreemSignatureHeader)
// 打印关键信息(避免输出完整敏感payload)
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
if setting.CreemTestMode {
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
} else if signature == "" {
log.Printf("Creem Webhook缺少签名头")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 验证签名
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
log.Printf("Creem Webhook签名验证失败")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
log.Printf("Creem Webhook签名验证成功")
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// 解析新格式的webhook数据
var webhookEvent CreemWebhookEvent
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
log.Printf("解析Creem Webhook参数失败: %v", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
// 根据事件类型处理不同的webhook
switch webhookEvent.EventType {
case "checkout.completed":
handleCheckoutCompleted(c, &webhookEvent)
default:
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
c.Status(http.StatusOK)
}
}
// 处理支付完成事件
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 验证订单状态
if event.Object.Order.Status != "paid" {
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
c.Status(http.StatusOK)
return
}
// 获取引用ID(这是我们创建订单时传递的request_id)
referenceId := event.Object.RequestId
if referenceId == "" {
log.Println("Creem Webhook缺少request_id字段")
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 验证订单类型,目前只处理一次性付款
if event.Object.Order.Type != "onetime" {
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
c.Status(http.StatusOK)
return
}
// 记录详细的支付信息
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
referenceId,
event.Object.Order.Id,
event.Object.Order.AmountPaid,
event.Object.Order.Currency,
event.Object.Product.Name)
// 查询本地订单确认存在
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Printf("Creem充值订单不存在: %s", referenceId)
c.AbortWithStatus(http.StatusBadRequest)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
return
}
// 处理充值,传入客户邮箱和姓名信息
customerEmail := event.Object.Customer.Email
customerName := event.Object.Customer.Name
// 防护性检查,确保邮箱和姓名不为空字符串
if customerEmail == "" {
log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
}
if customerName == "" {
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
}
err := model.RechargeCreem(referenceId, customerEmail, customerName)
if err != nil {
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
referenceId, topUp.Amount, topUp.Money)
c.Status(http.StatusOK)
}
type CreemCheckoutRequest struct {
ProductId string `json:"product_id"`
RequestId string `json:"request_id"`
Customer struct {
Email string `json:"email"`
} `json:"customer"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type CreemCheckoutResponse struct {
CheckoutUrl string `json:"checkout_url"`
Id string `json:"id"`
}
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
if setting.CreemApiKey == "" {
return "", fmt.Errorf("未配置Creem API密钥")
}
// 根据测试模式选择 API 端点
apiUrl := "https://api.creem.io/v1/checkouts"
if setting.CreemTestMode {
apiUrl = "https://test-api.creem.io/v1/checkouts"
log.Printf("使用Creem测试环境: %s", apiUrl)
}
// 构建请求数据,确保包含用户邮箱
requestData := CreemCheckoutRequest{
ProductId: product.ProductId,
RequestId: referenceId, // 这个作为订单ID传递给Creem
Customer: struct {
Email string `json:"email"`
}{
Email: email, // 用户邮箱会在支付页面预填充
},
Metadata: map[string]string{
"username": username,
"reference_id": referenceId,
"product_name": product.Name,
"quota": fmt.Sprintf("%d", product.Quota),
},
}
// 序列化请求数据
jsonData, err := json.Marshal(requestData)
if err != nil {
return "", fmt.Errorf("序列化请求数据失败: %v", err)
}
// 创建 HTTP 请求
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", setting.CreemApiKey)
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
apiUrl, product.ProductId, email, referenceId)
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
// 检查响应状态
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
}
// 解析响应
var checkoutResp CreemCheckoutResponse
err = json.Unmarshal(body, &checkoutResp)
if err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
}
if checkoutResp.CheckoutUrl == "" {
return "", fmt.Errorf("Creem API resp no checkout url ")
}
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
return checkoutResp.CheckoutUrl, nil
}
+28 -15
View File
@@ -5,13 +5,16 @@ import (
"io"
"log"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
@@ -81,12 +84,13 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
}
topUp := &model.TopUp{
UserId: id,
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
UserId: id,
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
@@ -215,15 +219,16 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(setting.ServerAddress + "/log"),
CancelURL: stripe.String(setting.ServerAddress + "/topup"),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),
Quantity: stripe.Int64(amount),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
}
if "" == customerId {
@@ -254,7 +259,8 @@ func GetChargedAmount(count float64, user model.User) float64 {
}
func getStripePayMoney(amount float64, group string) float64 {
if !common.DisplayInCurrencyEnabled {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
@@ -262,13 +268,20 @@ func getStripePayMoney(amount float64, group string) float64 {
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
// apply optional preset discount by the original request amount (if configured), default 1.0
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount
return payMoney
}
func getStripeMinTopup() int64 {
minTopup := setting.StripeMinTopUp
if !common.DisplayInCurrencyEnabled {
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
+9 -8
View File
@@ -4,10 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -70,7 +71,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "生成2FA密钥失败",
})
common.SysError("生成TOTP密钥失败: " + err.Error())
common.SysLog("生成TOTP密钥失败: " + err.Error())
return
}
@@ -81,7 +82,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
common.SysLog("生成备用码失败: " + err.Error())
return
}
@@ -115,7 +116,7 @@ func Setup2FA(c *gin.Context) {
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
common.SysLog("保存备用码失败: " + err.Error())
return
}
@@ -294,7 +295,7 @@ func Get2FAStatus(c *gin.Context) {
// 获取剩余备用码数量
backupCount, err := model.GetUnusedBackupCodeCount(userId)
if err != nil {
common.SysError("获取备用码数量失败: " + err.Error())
common.SysLog("获取备用码数量失败: " + err.Error())
} else {
status["backup_codes_remaining"] = backupCount
}
@@ -368,7 +369,7 @@ func RegenerateBackupCodes(c *gin.Context) {
"success": false,
"message": "生成备用码失败",
})
common.SysError("生成备用码失败: " + err.Error())
common.SysLog("生成备用码失败: " + err.Error())
return
}
@@ -378,7 +379,7 @@ func RegenerateBackupCodes(c *gin.Context) {
"success": false,
"message": "保存备用码失败",
})
common.SysError("保存备用码失败: " + err.Error())
common.SysLog("保存备用码失败: " + err.Error())
return
}
+17 -16
View File
@@ -5,11 +5,12 @@ import (
"encoding/json"
"errors"
"net/http"
"one-api/setting/console_setting"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
@@ -31,7 +32,7 @@ type Monitor struct {
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
Monitors []Monitor `json:"monitors"`
Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@@ -57,29 +58,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
result := UptimeGroupResult{
CategoryName: categoryName,
Monitors: []Monitor{},
Monitors: []Monitor{},
}
if url == "" || slug == "" {
return result
}
baseURL := strings.TrimSuffix(url, "/")
var statusData struct {
PublicGroupList []struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"id"`
Name string `json:"name"`
MonitorList []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"monitorList"`
} `json:"publicGroupList"`
}
var heartbeatData struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
@@ -88,11 +89,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
g.Go(func() error {
return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
if g.Wait() != nil {
@@ -139,7 +140,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
@@ -148,7 +149,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
return nil
})
}
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
}
}
+3 -2
View File
@@ -2,10 +2,11 @@ package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
+334 -22
View File
@@ -5,15 +5,18 @@ import (
"fmt"
"net/http"
"net/url"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting"
"strconv"
"strings"
"sync"
"one-api/constant"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -62,7 +65,7 @@ func Login(c *gin.Context) {
})
return
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
// 设置pending session,等待2FA验证
@@ -77,7 +80,7 @@ func Login(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "请输入两步验证码",
"success": true,
@@ -87,7 +90,7 @@ func Login(c *gin.Context) {
})
return
}
setupLogin(&user, c)
}
@@ -192,7 +195,7 @@ func Register(c *gin.Context) {
"success": false,
"message": "数据库错误,请稍后重试",
})
common.SysError(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
return
}
if exist {
@@ -209,6 +212,7 @@ func Register(c *gin.Context) {
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
Role: common.RoleCommonUser, // 明确设置角色为普通用户
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
@@ -235,7 +239,7 @@ func Register(c *gin.Context) {
"success": false,
"message": "生成默认令牌失败",
})
common.SysError("failed to generate token key: " + err.Error())
common.SysLog("failed to generate token key: " + err.Error())
return
}
// 生成默认令牌
@@ -342,7 +346,7 @@ func GenerateAccessToken(c *gin.Context) {
"success": false,
"message": "生成失败",
})
common.SysError("failed to generate key: " + err.Error())
common.SysLog("failed to generate key: " + err.Error())
return
}
user.SetAccessToken(key)
@@ -425,6 +429,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
userRole := c.GetInt("role")
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
@@ -433,14 +438,139 @@ func GetSelf(c *gin.Context) {
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
permissions := calculateUserPermissions(userRole)
// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"email": user.Email,
"github_id": user.GitHubId,
"discord_id": user.DiscordId,
"oidc_id": user.OidcId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
"group": user.Group,
"quota": user.Quota,
"used_quota": user.UsedQuota,
"request_count": user.RequestCount,
"aff_code": user.AffCode,
"aff_count": user.AffCount,
"aff_quota": user.AffQuota,
"aff_history_quota": user.AffHistoryQuota,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": user,
"data": responseData,
})
return
}
// 计算用户权限的辅助函数
func calculateUserPermissions(userRole int) map[string]interface{} {
permissions := map[string]interface{}{}
// 根据用户角色计算权限
if userRole == common.RoleRootUser {
// 超级管理员不需要边栏设置功能
permissions["sidebar_settings"] = false
permissions["sidebar_modules"] = map[string]interface{}{}
} else if userRole == common.RoleAdminUser {
// 管理员可以设置边栏,但不包含系统设置功能
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": map[string]interface{}{
"setting": false, // 管理员不能访问系统设置
},
}
} else {
// 普通用户只能设置个人功能,不包含管理员区域
permissions["sidebar_settings"] = true
permissions["sidebar_modules"] = map[string]interface{}{
"admin": false, // 普通用户不能访问管理员区域
}
}
return permissions
}
// 根据用户角色生成默认的边栏配置
func generateDefaultSidebarConfig(userRole int) string {
defaultConfig := map[string]interface{}{}
// 聊天区域 - 所有用户都可以访问
defaultConfig["chat"] = map[string]interface{}{
"enabled": true,
"playground": true,
"chat": true,
}
// 控制台区域 - 所有用户都可以访问
defaultConfig["console"] = map[string]interface{}{
"enabled": true,
"detail": true,
"token": true,
"log": true,
"midjourney": true,
"task": true,
}
// 个人中心区域 - 所有用户都可以访问
defaultConfig["personal"] = map[string]interface{}{
"enabled": true,
"topup": true,
"personal": true,
}
// 管理员区域 - 根据角色决定
if userRole == common.RoleAdminUser {
// 管理员可以访问管理员区域,但不能访问系统设置
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": false, // 管理员不能访问系统设置
}
} else if userRole == common.RoleRootUser {
// 超级管理员可以访问所有功能
defaultConfig["admin"] = map[string]interface{}{
"enabled": true,
"channel": true,
"models": true,
"redemption": true,
"user": true,
"setting": true,
}
}
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
}
return string(configBytes)
}
func GetUserModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -451,7 +581,7 @@ func GetUserModels(c *gin.Context) {
common.ApiError(c, err)
return
}
groups := setting.GetUserUsableGroups(user.Group)
groups := service.GetUserUsableGroups(user.Group)
var models []string
for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) {
@@ -517,7 +647,7 @@ func UpdateUser(c *gin.Context) {
return
}
if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -527,8 +657,8 @@ func UpdateUser(c *gin.Context) {
}
func UpdateSelf(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -536,6 +666,60 @@ func UpdateSelf(c *gin.Context) {
})
return
}
// 检查是否是sidebar_modules更新请求
if sidebarModules, exists := requestData["sidebar_modules"]; exists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}
// 获取当前用户设置
currentSetting := user.GetSetting()
// 更新sidebar_modules字段
if sidebarModulesStr, ok := sidebarModules.(string); ok {
currentSetting.SidebarModules = sidebarModulesStr
}
// 保存更新后的设置
user.SetSetting(currentSetting)
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "更新设置失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "设置更新成功",
})
return
}
// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
err = json.Unmarshal(requestDataBytes, &user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
@@ -678,6 +862,7 @@ func CreateUser(c *gin.Context) {
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
Role: user.Role, // 保持管理员设置的角色
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
@@ -843,18 +1028,64 @@ type topUpRequest struct {
Key string `json:"key"`
}
var topUpLock = sync.Mutex{}
var topUpLocks sync.Map
var topUpCreateLock sync.Mutex
type topUpTryLock struct {
ch chan struct{}
}
func newTopUpTryLock() *topUpTryLock {
return &topUpTryLock{ch: make(chan struct{}, 1)}
}
func (l *topUpTryLock) TryLock() bool {
select {
case l.ch <- struct{}{}:
return true
default:
return false
}
}
func (l *topUpTryLock) Unlock() {
select {
case <-l.ch:
default:
}
}
func getTopUpLock(userID int) *topUpTryLock {
if v, ok := topUpLocks.Load(userID); ok {
return v.(*topUpTryLock)
}
topUpCreateLock.Lock()
defer topUpCreateLock.Unlock()
if v, ok := topUpLocks.Load(userID); ok {
return v.(*topUpTryLock)
}
l := newTopUpTryLock()
topUpLocks.Store(userID, l)
return l
}
func TopUp(c *gin.Context) {
topUpLock.Lock()
defer topUpLock.Unlock()
id := c.GetInt("id")
lock := getTopUpLock(id)
if !lock.TryLock() {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "充值处理中,请稍后重试",
})
return
}
defer lock.Unlock()
req := topUpRequest{}
err := c.ShouldBindJSON(&req)
if err != nil {
common.ApiError(c, err)
return
}
id := c.GetInt("id")
quota, err := model.Redeem(req.Key, id)
if err != nil {
common.ApiError(c, err)
@@ -865,7 +1096,6 @@ func TopUp(c *gin.Context) {
"message": "",
"data": quota,
})
return
}
type UpdateUserSettingRequest struct {
@@ -874,6 +1104,10 @@ type UpdateUserSettingRequest struct {
WebhookUrl string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,omitempty"`
BarkUrl string `json:"bark_url,omitempty"`
GotifyUrl string `json:"gotify_url,omitempty"`
GotifyToken string `json:"gotify_token,omitempty"`
GotifyPriority int `json:"gotify_priority,omitempty"`
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
RecordIpLog bool `json:"record_ip_log"`
}
@@ -889,7 +1123,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的预警类型",
@@ -937,6 +1171,67 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是Bark类型,验证Bark URL
if req.QuotaWarningType == dto.NotifyTypeBark {
if req.BarkUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Bark推送URL",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Bark推送URL必须以http://或https://开头",
})
return
}
}
// 如果是Gotify类型,验证Gotify URL和Token
if req.QuotaWarningType == dto.NotifyTypeGotify {
if req.GotifyUrl == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址不能为空",
})
return
}
if req.GotifyToken == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify令牌不能为空",
})
return
}
// 验证URL格式
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的Gotify服务器地址",
})
return
}
// 检查是否是HTTP或HTTPS
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "Gotify服务器地址必须以http://或https://开头",
})
return
}
}
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -965,6 +1260,23 @@ func UpdateUserSetting(c *gin.Context) {
settings.NotificationEmail = req.NotificationEmail
}
// 如果是Bark类型,添加Bark URL到设置中
if req.QuotaWarningType == dto.NotifyTypeBark {
settings.BarkUrl = req.BarkUrl
}
// 如果是Gotify类型,添加Gotify配置到设置中
if req.QuotaWarningType == dto.NotifyTypeGotify {
settings.GotifyUrl = req.GotifyUrl
settings.GotifyToken = req.GotifyToken
// Gotify优先级范围0-10,超出范围则使用默认值5
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
settings.GotifyPriority = 5
} else {
settings.GotifyPriority = req.GotifyPriority
}
}
// 更新用户设置
user.SetSetting(settings)
if err := user.Update(false); err != nil {
+124
View File
@@ -0,0 +1,124 @@
package controller
import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// GetAllVendors 获取供应商列表(分页)
func GetAllVendors(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
var total int64
model.DB.Model(&model.Vendor{}).Count(&total)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// SearchVendors 搜索供应商
func SearchVendors(c *gin.Context) {
keyword := c.Query("keyword")
pageInfo := common.GetPageQuery(c)
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// GetVendorMeta 根据 ID 获取供应商
func GetVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
v, err := model.GetVendorByID(id)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, v)
}
// CreateVendorMeta 新建供应商
func CreateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Name == "" {
common.ApiErrorMsg(c, "供应商名称不能为空")
return
}
// 创建前先检查名称
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// UpdateVendorMeta 更新供应商
func UpdateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Id == 0 {
common.ApiErrorMsg(c, "缺少供应商 ID")
return
}
// 名称冲突检查
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// DeleteVendorMeta 删除供应商
func DeleteVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+176
View File
@@ -0,0 +1,176 @@
package controller
import (
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
func VideoProxy(c *gin.Context) {
taskID := c.Param("task_id")
if taskID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": "task_id is required",
"type": "invalid_request_error",
},
})
return
}
task, exists, err := model.GetByOnlyTaskId(taskID)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to query task",
"type": "server_error",
},
})
return
}
if !exists || task == nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err))
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"message": "Task not found",
"type": "invalid_request_error",
},
})
return
}
if task.Status != model.TaskStatusSuccess {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": fmt.Sprintf("Task is not completed yet, current status: %s", task.Status),
"type": "invalid_request_error",
},
})
return
}
channel, err := model.CacheGetChannel(task.ChannelId)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to retrieve channel information",
"type": "server_error",
},
})
return
}
baseURL := channel.GetBaseURL()
if baseURL == "" {
baseURL = "https://api.openai.com"
}
var videoURL string
client := &http.Client{
Timeout: 60 * time.Second,
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
"type": "server_error",
},
})
return
}
switch channel.Type {
case constant.ChannelTypeGemini:
apiKey := task.PrivateData.Key
if apiKey == "" {
logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "API key not stored for task",
"type": "server_error",
},
})
return
}
videoURL, err = getGeminiVideoURL(channel, task, apiKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error()))
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"message": "Failed to resolve Gemini video URL",
"type": "server_error",
},
})
return
}
req.Header.Set("x-goog-api-key", apiKey)
case constant.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)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
"type": "server_error",
},
})
return
}
resp, err := client.Do(req)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error()))
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"message": "Failed to fetch video content",
"type": "server_error",
},
})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL))
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"message": fmt.Sprintf("Upstream service returned status %d", resp.StatusCode),
"type": "server_error",
},
})
return
}
for key, values := range resp.Header {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
c.Writer.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 24 hours
c.Writer.WriteHeader(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error()))
}
}
+158
View File
@@ -0,0 +1,158 @@
package controller
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
)
func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
if channel == nil || task == nil {
return "", fmt.Errorf("invalid channel or task")
}
if url := extractGeminiVideoURLFromTaskData(task); url != "" {
return ensureAPIKey(url, apiKey), nil
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
if adaptor == nil {
return "", fmt.Errorf("gemini task adaptor not found")
}
if apiKey == "" {
return "", fmt.Errorf("api key not available for task")
}
resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
"task_id": task.TaskID,
"action": task.Action,
})
if err != nil {
return "", fmt.Errorf("fetch task failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read task response failed: %w", err)
}
taskInfo, parseErr := adaptor.ParseTaskResult(body)
if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
}
if url := extractGeminiVideoURLFromPayload(body); url != "" {
return ensureAPIKey(url, apiKey), nil
}
if parseErr != nil {
return "", fmt.Errorf("parse task result failed: %w", parseErr)
}
return "", fmt.Errorf("gemini video url not found")
}
func extractGeminiVideoURLFromTaskData(task *model.Task) string {
if task == nil || len(task.Data) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(task.Data, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromPayload(body []byte) string {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromMap(payload map[string]any) string {
if payload == nil {
return ""
}
if uri, ok := payload["uri"].(string); ok && uri != "" {
return uri
}
if resp, ok := payload["response"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
return uri
}
}
return ""
}
func extractGeminiVideoURLFromResponse(resp map[string]any) string {
if resp == nil {
return ""
}
if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
return uri
}
}
if videos, ok := resp["videos"].([]any); ok {
for _, video := range videos {
if vm, ok := video.(map[string]any); ok {
if uri, ok := vm["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
if uri, ok := resp["video"].(string); ok && uri != "" {
return uri
}
if uri, ok := resp["uri"].(string); ok && uri != "" {
return uri
}
return ""
}
func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
if gvr == nil {
return ""
}
if samples, ok := gvr["generatedSamples"].([]any); ok {
for _, sample := range samples {
if sm, ok := sample.(map[string]any); ok {
if video, ok := sm["video"].(map[string]any); ok {
if uri, ok := video["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
}
return ""
}
func ensureAPIKey(uri, key string) string {
if key == "" || uri == "" {
return uri
}
if strings.Contains(uri, "key=") {
return uri
}
if strings.Contains(uri, "?") {
return fmt.Sprintf("%s&key=%s", uri, key)
}
return fmt.Sprintf("%s?key=%s", uri, key)
}
+3 -2
View File
@@ -5,11 +5,12 @@ import (
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
+51 -19
View File
@@ -1,4 +1,18 @@
version: '3.4'
# New-API Docker Compose Configuration
#
# Quick Start:
# 1. docker-compose up -d
# 2. Access at http://localhost:3000
#
# Using MySQL instead of PostgreSQL:
# 1. Comment out the postgres service and SQL_DSN line 15
# 2. Uncomment the mysql service and SQL_DSN line 16
# 3. Uncomment mysql in depends_on (line 28)
# 4. Uncomment mysql_data in volumes section (line 64)
#
# ⚠️ IMPORTANT: Change all default passwords before deploying to production!
version: '3.4' # For compatibility with older Docker versions
services:
new-api:
@@ -12,21 +26,25 @@ services:
- ./data:/data
- ./logs:/app/logs
environment:
- SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
- REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录
# - STREAMING_TIMEOUT=120 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!!
# - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL)
depends_on:
- redis
- mysql
- postgres
# - mysql # Uncomment if using MySQL
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"]
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -36,17 +54,31 @@ services:
container_name: redis
restart: always
mysql:
image: mysql:8.2
container_name: mysql
postgres:
image: postgres:15
container_name: postgres
restart: always
environment:
MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN
MYSQL_DATABASE: new-api
POSTGRES_USER: root
POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
POSTGRES_DB: new-api
volumes:
- mysql_data:/var/lib/mysql
# ports:
# - "3306:3306" # If you want to access MySQL from outside Docker, uncomment
- pg_data:/var/lib/postgresql/data
# ports:
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
# mysql:
# image: mysql:8.2
# container_name: mysql
# restart: always
# environment:
# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
# MYSQL_DATABASE: new-api
# volumes:
# - mysql_data:/var/lib/mysql
# ports:
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
volumes:
mysql_data:
pg_data:
# mysql_data:
-53
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` 替换为实际的值。
-195
View File
@@ -1,195 +0,0 @@
# One API Web 界面后端接口文档
> 本文档汇总了 **One 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.45 66.73">
<defs>
<style>
.cls-1 {
fill: #ea5e5d;
}
.cls-2 {
fill: #23af69;
}
.cls-3 {
fill: #ea5756;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层_1">
<g>
<g>
<g>
<path class="cls-1" d="M16.72,51.21c-4.45,0-8.64-1.78-11.81-5.01-3.17-3.23-4.91-7.51-4.91-12.04s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.71,1.82,11.82,4.99c2.32,2.36,2.32,6.2,0,8.56-2.32,2.36-6.08,2.36-8.4,0-.9-.92-2.15-1.45-3.43-1.45-2.63,0-4.85,2.26-4.85,4.94s2.22,4.94,4.85,4.94c1.28,0,2.52-.53,3.43-1.45,2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-3.11,3.17-7.42,4.99-11.82,4.99Z"/>
<path class="cls-1" d="M32.05,66.73c-4.45,0-8.64-1.78-11.81-5.01s-4.91-7.51-4.91-12.04,1.79-8.88,4.9-12.06c2.32-2.36,6.08-2.36,8.4,0,2.32,2.36,2.32,6.2,0,8.56-.9.92-1.42,2.19-1.42,3.49,0,2.68,2.22,4.94,4.85,4.94s4.85-2.26,4.85-4.94c0-.95-.23-2.31-1.32-3.43-3.13-3.19-4.92-7.6-4.92-12.09s1.74-8.81,4.91-12.04,7.36-5.01,11.81-5.01,8.64,1.78,11.81,5.01,4.91,7.51,4.91,12.04-1.79,8.88-4.9,12.06c-2.32,2.36-6.08,2.36-8.4,0-2.32-2.36-2.32-6.2,0-8.56.9-.92,1.42-2.19,1.42-3.49,0-2.68-2.22-4.94-4.85-4.94s-4.85,2.26-4.85,4.94c0,1.31.53,2.6,1.45,3.53,3.1,3.16,4.8,7.42,4.8,11.99s-1.74,8.81-4.91,12.04c-3.17,3.23-7.36,5.01-11.81,5.01Z"/>
</g>
<path class="cls-2" d="M32.05,19.09l-9.72-9.12c-1.5-1.4-1.57-3.75-.17-5.25,1.4-1.49,3.75-1.57,5.25-.17l3.89,3.65,5.53-6.83c1.29-1.59,3.63-1.84,5.22-.55,1.59,1.29,1.84,3.63.55,5.22l-10.56,13.05Z"/>
</g>
<g>
<path class="cls-3" d="M93.93,24.6l.55-.39c.69-.4,1.17-.61,1.46-.61.63,0,1.3.57,2.03,1.7.44.71.67,1.27.67,1.7s-.14.78-.41,1.06c-.27.28-.59.54-.96.76-.36.22-.71.43-1.05.64-.33.2-1.02.47-2.05.79-1.03.32-2.03.49-2.99.49s-1.93-.13-2.91-.38c-.98-.25-1.99-.68-3.03-1.27-1.04-.6-1.98-1.32-2.81-2.18-.83-.86-1.51-1.96-2.05-3.31-.54-1.35-.8-2.81-.8-4.38s.26-3.01.79-4.29c.53-1.28,1.2-2.35,2.02-3.19.82-.84,1.75-1.54,2.81-2.11,1.98-1.09,3.97-1.64,5.98-1.64.95,0,1.92.15,2.9.44.98.29,1.72.59,2.23.9l.73.42c.36.22.65.4.85.55.53.42.79.91.79,1.44s-.21,1.1-.64,1.68c-.79,1.09-1.5,1.64-2.12,1.64-.36,0-.88-.22-1.55-.67-.85-.69-1.98-1.03-3.4-1.03-1.31,0-2.61.46-3.88,1.36-.61.44-1.11,1.07-1.52,1.88-.4.81-.61,1.72-.61,2.75s.2,1.94.61,2.75c.4.81.92,1.45,1.55,1.91,1.23.89,2.52,1.34,3.85,1.34.63,0,1.22-.08,1.77-.24.56-.16.96-.32,1.2-.49Z"/>
<path class="cls-3" d="M114.38,9.07c.16-.3.43-.52.82-.64.38-.12.87-.18,1.46-.18s1.05.05,1.4.15c.34.1.61.22.79.36.18.14.32.34.42.61.1.34.15.87.15,1.58v16.84c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58v-6.16h-8.04v6.19c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V10.92c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v6.19h8.04v-6.22c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8Z"/>
<path class="cls-3" d="M127.21,25.1h9.34c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-12.01c-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55V10.9c0-1.03.19-1.73.58-2.11.38-.37,1.11-.56,2.18-.56h11.95c.47,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.37,2.25-1.12,2.49-.34.12-.87.18-1.58.18h-9.31v3.06h6.01c.46,0,.81.02,1.05.05.23.03.5.13.8.29.55.28.82,1.07.82,2.37,0,1.42-.38,2.25-1.15,2.49-.34.12-.87.18-1.58.18h-5.95v3.06Z"/>
<path class="cls-3" d="M196.96,8.79c.99.69,1.49,1.35,1.49,2,0,.38-.23.92-.7,1.61l-6.55,9.8v5.79c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.16.3-.43.52-.82.64-.38.12-.9.18-1.55.18s-1.16-.06-1.55-.18c-.38-.12-.66-.34-.82-.65-.16-.31-.26-.59-.29-.82-.03-.23-.05-.59-.05-1.08v-5.73l-6.55-9.8c-.47-.69-.7-1.22-.7-1.61,0-.65.44-1.27,1.33-1.87.89-.6,1.53-.9,1.91-.9s.69.08.91.24c.34.22.71.64,1.09,1.24l4.7,7.52,4.7-7.52c.38-.61.72-1.01,1-1.2s.61-.29.99-.29.97.25,1.77.76Z"/>
<g>
<path class="cls-3" d="M81.93,56.63c-.53-.65-.79-1.23-.79-1.74s.43-1.2,1.3-2.05c.51-.49,1.04-.73,1.61-.73s1.36.51,2.37,1.52c.28.34.69.67,1.21.99.53.31,1.01.47,1.46.47,1.88,0,2.82-.77,2.82-2.31,0-.46-.26-.85-.77-1.17-.52-.31-1.16-.54-1.93-.68-.77-.14-1.6-.37-2.49-.68-.89-.31-1.72-.68-2.49-1.11-.77-.42-1.41-1.1-1.93-2.02-.52-.92-.77-2.03-.77-3.32,0-1.78.66-3.33,1.99-4.66s3.13-1.99,5.42-1.99c1.21,0,2.32.16,3.32.47,1,.31,1.69.63,2.08.96l.76.58c.63.59.94,1.08.94,1.49s-.24.96-.73,1.67c-.69,1.01-1.4,1.52-2.12,1.52-.42,0-.95-.2-1.58-.61-.06-.04-.18-.14-.35-.3-.17-.16-.33-.29-.47-.39-.42-.26-.97-.39-1.62-.39s-1.2.16-1.64.47c-.43.31-.65.75-.65,1.3s.26,1.01.77,1.35c.52.34,1.16.58,1.93.7.77.12,1.61.31,2.52.56.91.25,1.75.56,2.52.93.77.36,1.41,1,1.93,1.9.52.9.77,2.01.77,3.32s-.26,2.47-.79,3.47c-.53,1-1.21,1.77-2.06,2.32-1.64,1.07-3.39,1.61-5.25,1.61-.95,0-1.85-.12-2.7-.35-.85-.23-1.54-.52-2.06-.86-1.07-.65-1.82-1.27-2.24-1.88l-.27-.33Z"/>
<path class="cls-3" d="M100.74,37.49h16.87c.65,0,1.12.08,1.43.23.3.15.51.39.61.71.1.32.15.75.15,1.27s-.05.95-.15,1.26c-.1.31-.27.53-.52.65-.36.18-.88.27-1.55.27h-5.79v15.26c0,.47-.02.81-.05,1.03s-.12.48-.27.77c-.15.29-.42.5-.8.62-.38.12-.89.18-1.52.18s-1.13-.06-1.5-.18c-.37-.12-.64-.33-.79-.62-.15-.29-.24-.56-.27-.79-.03-.23-.05-.58-.05-1.05v-15.23h-5.82c-.65,0-1.12-.08-1.43-.23-.3-.15-.51-.39-.61-.71-.1-.32-.15-.75-.15-1.27s.05-.95.15-1.26c.1-.31.27-.53.52-.65.36-.18.88-.27,1.55-.27Z"/>
<path class="cls-3" d="M135.99,38.34c.2-.32.5-.55.88-.67.38-.12.86-.18,1.44-.18s1.04.05,1.38.15c.34.1.61.22.79.36.18.14.31.35.39.64.12.34.18.87.18,1.58v9.16c0,2.67-.83,5.1-2.49,7.28-.81,1.03-1.85,1.87-3.12,2.5s-2.68.96-4.23.96-2.95-.32-4.22-.97c-1.26-.65-2.29-1.5-3.08-2.55-1.64-2.14-2.46-4.57-2.46-7.28v-9.13c0-.49.02-.84.05-1.08.03-.23.13-.5.29-.8.16-.3.43-.52.82-.64.38-.12.9-.18,1.55-.18s1.16.06,1.55.18c.38.12.65.33.79.64.24.47.36,1.1.36,1.91v9.1c0,1.23.3,2.41.91,3.52.3.57.76,1.02,1.37,1.36.61.34,1.32.52,2.15.52,1.48,0,2.58-.55,3.31-1.64.73-1.09,1.09-2.36,1.09-3.79v-9.28c0-.79.1-1.34.3-1.67Z"/>
<path class="cls-3" d="M146.18,37.49l5.61.03c2.93,0,5.51,1.06,7.74,3.17,2.22,2.11,3.34,4.71,3.34,7.8s-1.09,5.73-3.26,7.93c-2.17,2.2-4.81,3.31-7.9,3.31h-5.55c-1.23,0-2-.25-2.31-.76-.24-.42-.36-1.07-.36-1.94v-16.87c0-.49.02-.84.05-1.06s.13-.49.29-.79c.28-.55,1.07-.82,2.37-.82ZM151.79,54.35c1.46,0,2.77-.54,3.94-1.62,1.17-1.08,1.76-2.44,1.76-4.08s-.57-3.01-1.71-4.11c-1.14-1.1-2.48-1.65-4.02-1.65h-2.91v11.47h2.94Z"/>
<path class="cls-3" d="M164.84,40.19c0-.46.02-.81.05-1.05.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82,1.42,0,2.25.37,2.52,1.12.1.34.15.87.15,1.58v16.87c0,.49-.02.84-.05,1.06s-.13.49-.29.79c-.28.55-1.07.82-2.37.82-1.42,0-2.25-.38-2.49-1.15-.12-.32-.18-.84-.18-1.55v-16.87Z"/>
<path class="cls-3" d="M183.07,37.24c2.99,0,5.59,1.08,7.8,3.25,2.2,2.16,3.31,4.85,3.31,8.05s-1.05,5.94-3.16,8.19c-2.1,2.26-4.69,3.38-7.77,3.38s-5.69-1.11-7.84-3.34c-2.15-2.22-3.23-4.87-3.23-7.95,0-1.68.3-3.25.91-4.72.61-1.47,1.42-2.7,2.43-3.69,1.01-.99,2.17-1.77,3.49-2.34,1.31-.57,2.67-.85,4.07-.85ZM177.55,48.68c0,1.8.58,3.26,1.74,4.38,1.16,1.12,2.46,1.68,3.9,1.68s2.73-.55,3.88-1.64c1.15-1.09,1.73-2.56,1.73-4.4s-.58-3.32-1.74-4.43c-1.16-1.11-2.46-1.67-3.9-1.67s-2.73.56-3.88,1.68c-1.15,1.12-1.73,2.58-1.73,4.38Z"/>
</g>
<g>
<path class="cls-3" d="M176.92,11.06c-.03-.23-.13-.5-.29-.8-.28-.55-1.07-.82-2.37-.82h-6.55c-1.78,0-3.51.65-5.19,1.94-.81.63-1.48,1.48-2,2.55-.53,1.07-.79,2.27-.79,3.58,0,2.29.76,4.17,2.28,5.64-.44,1.07-1.13,2.66-2.06,4.76-.3.73-.45,1.25-.45,1.58,0,.77.63,1.42,1.88,1.94.65.28,1.17.43,1.56.43s.72-.1.97-.29c.25-.19.44-.39.56-.59.2-.38.99-2.21,2.37-5.49l.94.06h3.82v3.43c0,.47.02.81.05,1.05.03.23.13.5.29.8.28.55,1.07.82,2.37.82,1.42,0,2.25-.37,2.49-1.12.12-.34.18-.87.18-1.58V12.11c0-.46-.02-.81-.05-1.05ZM172.81,19.44c-.09.14-.48.77-1.24.91-.2.04-.37.03-.48.02-.02.14-.04.26-.06.38-.16.83-.38,1.05-.57,1.07-.29.05-.51-.35-.93-.9-.23.01-.46.02-.69.02-.51,0-1.01-.03-1.49-.09-.25-.03-.5-.07-.74-.11-1.18-.32-2.03-1.27-2.03-2.4v-1.37c0-1.13.86-2.08,2.03-2.4.24-.04.49-.08.74-.11.48-.06.98-.09,1.49-.09s1.01.03,1.49.09c.25.03.5.07.74.11.6.16,1.12.49,1.49.93.34.41.55.92.55,1.47v1.37c0,.23-.01.66-.29,1.1Z"/>
<circle class="cls-2" cx="167.24" cy="17.67" r=".49"/>
<circle class="cls-2" cx="168.88" cy="17.71" r=".49"/>
<circle class="cls-2" cx="170.59" cy="17.71" r=".49"/>
</g>
<g>
<path class="cls-3" d="M141.01,8.24c.03-.23.13-.5.29-.8.28-.55,1.07-.82,2.37-.82h6.55c1.78,0,3.51.65,5.19,1.94.81.63,1.48,1.48,2,2.55.53,1.07.79,2.27.79,3.58,0,2.29-.76,4.17-2.28,5.64.44,1.07,1.13,2.66,2.06,4.76.3.73.45,1.25.45,1.58,0,.77-.63,1.42-1.88,1.94-.65.28-1.17.43-1.56.43s-.72-.1-.97-.29c-.25-.19-.44-.39-.56-.59-.2-.38-.99-2.21-2.37-5.49l-.94.06h-3.82v3.43c0,.47-.02.81-.05,1.05-.03.23-.13.5-.29.8-.28.55-1.07.82-2.37.82-1.42,0-2.25-.37-2.49-1.12-.12-.34-.18-.87-.18-1.58V9.28c0-.46.02-.81.05-1.05ZM145.12,16.62c.09.14.48.77,1.24.91.2.04.37.03.48.02.02.14.04.26.06.38.16.83.38,1.05.57,1.07.29.05.51-.35.93-.9.23.01.46.02.69.02.51,0,1.01-.03,1.49-.09.25-.03.5-.07.74-.11,1.18-.32,2.03-1.27,2.03-2.4v-1.37c0-1.13-.86-2.08-2.03-2.4-.24-.04-.49-.08-.74-.11-.48-.06-.98-.09-1.49-.09s-1.01.03-1.49.09c-.25.03-.5.07-.74.11-.6.16-1.12.49-1.49.93-.34.41-.55.92-.55,1.47v1.37c0,.23.01.66.29,1.1Z"/>
<circle class="cls-2" cx="150.69" cy="14.84" r=".49"/>
<circle class="cls-2" cx="149.05" cy="14.89" r=".49"/>
<circle class="cls-2" cx="147.35" cy="14.89" r=".49"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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