Compare commits

...

161 Commits

Author SHA1 Message Date
CaIon
01469aa01c refactor(adaptor): extract common header operations into a separate function 2025-10-02 15:28:09 +08:00
Calcium-Ion
0769184b9b Merge pull request #1956 from QuantumNous/alpha
alpha -> main
2025-10-02 15:26:59 +08:00
Seefs
4137120d69 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
3d1433dd70 Merge branch 'alpha' into pr/fix-video-get-task 2025-10-02 14:43:11 +08:00
Seefs
acbfc9d3b3 Merge pull request #1951 from feitianbubu/pr/add-doubao-video
增加豆包视频渠道
2025-10-02 14:41:41 +08:00
Seefs
4cdad47695 Merge pull request #1955 from seefs001/fix/megge-conflict
fix: merge conflict
2025-10-02 14:30:25 +08:00
Seefs
6a1de0ebdc fix: merge conflict 2025-10-02 14:28:58 +08:00
Seefs
92a4e88ceb 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
e9043590a9 Merge branch 'alpha' into feat-channel-block-edit 2025-10-02 14:16:01 +08:00
Seefs
9e6828653b 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
dae661bb53 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
649a5205c9 Merge pull request #1950 from seefs001/fix/missing-field
fix: missing field & field control
2025-10-02 14:10:11 +08:00
Seefs
26a563da54 fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields 2025-10-02 13:57:49 +08:00
Seefs
c1492be131 Merge pull request #1954 from QuantumNous/main
main -> alpha
2025-10-02 13:50:18 +08:00
feitianbubu
7ca65a5e8e feat: add doubao video add log detail 2025-10-02 04:00:50 +08:00
feitianbubu
b244a06ca1 feat: add doubao video use quota by total token 2025-10-02 04:00:46 +08:00
feitianbubu
c320410c84 feat: add doubao video generate 2025-10-02 04:00:43 +08:00
Seefs
2938246f2e 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
0e9ad4a15f fix: missing field & field control 2025-10-02 00:14:35 +08:00
RedwindA
2200bb9166 fix(openai): add nil checks for web_search streaming to prevent panic 2025-10-01 22:19:22 +08:00
RedwindA
d6db10b4bc feat: 添加 Bark 和 Gotify 通知的国际化支持 2025-10-01 19:36:19 +08:00
RedwindA
85ff8b1422 feat: add Gotify notification option for quota alerts 2025-10-01 19:15:00 +08:00
HynoR
1428338546 feat: Enhance SettingsChats edit interface 2025-10-01 18:40:02 +08:00
HynoR
ec76b0f5e2 feat: add visual editing mode for chat configurations 2025-10-01 18:22:32 +08:00
Seefs
96b172e93b 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
70263e96ab feat: 支持 gemini-robotics-er-1.5-preview 2025-10-01 17:33:54 +08:00
Seefs
15db5c0062 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
f5a774f22c fix(jina): remove encoding_format for jina embedding 2025-10-01 02:06:30 +08:00
CaIon
0b91e45197 fix(adaptor): update relay mode handling to support relay format 2025-09-30 16:53:57 +08:00
CaIon
6bc3e62fd5 feat: add endpoint type selection to channel testing functionality 2025-09-30 16:52:14 +08:00
Calcium-Ion
3ba2aaee32 Merge pull request #1936 from seefs001/fix/passkey
fix: passkey 文案
2025-09-30 16:19:01 +08:00
Seefs
0a6f39e60b fix: passkey 文案 2025-09-30 16:15:33 +08:00
Calcium-Ion
1bd791d603 Merge pull request #1934 from seefs001/fix/passkey
fix: passkey rpid detect
2025-09-30 15:54:49 +08:00
Seefs
fcc6172b43 fix: passkey rpid detect 2025-09-30 15:53:19 +08:00
Seefs
7533ffc3ee Merge pull request #1931 from seefs001/feature/passkey
fix: passkey security
2025-09-30 13:28:18 +08:00
Seefs
e71407ee62 fix: passkey security 2025-09-30 13:18:18 +08:00
Seefs
d026edc1b3 Merge pull request #1930 from seefs001/feature/passkey
fix: passkey model type
2025-09-30 12:52:32 +08:00
Seefs
ab166649bc fix: blob type 2025-09-30 12:40:05 +08:00
Seefs
9f20e49100 Merge pull request #1912 from seefs001/feature/passkey
feat: passkey
2025-09-30 12:28:09 +08:00
Seefs
013a575541 fix: personal setting 2025-09-30 12:26:24 +08:00
Seefs
4c13666f26 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
e8425addf0 feat: 通用二步验证 2025-09-30 12:12:50 +08:00
Seefs
aab82f22fa 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
8e10af82b1 fix(main): conditionally log missing .env file message based on debug mode 2025-09-30 11:22:00 +08:00
Calcium-Ion
595e3fed91 Merge pull request #1920 from QuantumNous/alpha
Alpha -> main
2025-09-30 11:16:45 +08:00
Seefs
933ab4340b Merge pull request #1925 from seefs001/feature/claude-context-editing
feat: claude context editing
2025-09-30 09:48:32 +08:00
Seefs
3b306bb5d3 Merge pull request #1926 from seefs001/fix/claude-beta
fix: claude beta=true
2025-09-30 09:48:18 +08:00
Seefs
8118424039 fix: claude beta=true 2025-09-30 09:46:46 +08:00
Seefs
7d7ffc05ad 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
31544405f4 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
30cb3b8bc2 feat: claude context editing 2025-09-30 09:22:40 +08:00
papersnake
d7db30a23e feat: support claude-sonnet-4-5-20250929 2025-09-30 09:14:12 +08:00
RedwindA
fa45cb5279 fix: Optimize sidebar refresh to avoid redundant loading states 2025-09-29 22:16:25 +08:00
Seefs
25a3896e5c Merge pull request #1919 from seefs001/fix/submodel
fix : fix submodel adapter
2025-09-29 21:56:53 +08:00
Seefs
76180b1df4 fix: submodel adapter 2025-09-29 21:55:34 +08:00
Seefs
84cdd24116 feat: passkey wip 2025-09-29 21:54:16 +08:00
Seefs
83b2b071fd Merge pull request #1915 from danding5/main
add submodel.ai
2025-09-29 21:54:00 +08:00
Seefs
4bb4b64184 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
6c0a79dab8 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
d249532473 fix: Redirect address after successful tg binding 2025-09-29 20:19:34 +08:00
dd
fc2d9922f8 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
1b627ddb5e fix: sync third-party binding state in personal settings 2025-09-29 19:23:42 +08:00
dd
8c5b6654cb Merge branch 'QuantumNous:main' into main 2025-09-29 19:15:43 +08:00
Seefs
9f989fc7ef Merge pull request #1913 from RedwindA/fix/deepseek-ratio
解锁deepseek补全倍率;允许deepseek渠道获取模型
2025-09-29 18:41:33 +08:00
RedwindA
ca0eaa7697 解锁deepseek补全倍率;允许deepseek渠道获取模型 2025-09-29 18:32:44 +08:00
Seefs
dcf4336c75 feat: passkey 2025-09-29 17:45:09 +08:00
CaIon
c7a52370fc fix(models): increase varchar length for TaskID and Username fields #1905 2025-09-29 16:45:46 +08:00
CaIon
c3660938e0 Merge remote-tracking branch 'origin/main' 2025-09-29 16:20:59 +08:00
Calcium-Ion
6983d9f91f Merge pull request #1889 from comeback01/traduction
feat(i18n): Add French language support.
2025-09-29 16:20:49 +08:00
CaIon
c2bbfd7fe7 chore: remove bun_output.log and package-lock.json files 2025-09-29 16:19:54 +08:00
dd
d0a850468d Update render.jsx 2025-09-29 15:14:02 +08:00
comeback01
e71df436e2 feat(i18n): add French translation for stripe promo codes 2025-09-29 09:12:37 +02:00
dd
5840de1df8 Update relay_adaptor.go 2025-09-29 15:11:17 +08:00
comeback01
9c2082f41c Chore: remove temporary verification script 2025-09-29 09:03:54 +02:00
comeback01
e647878031 Merge branch 'main' into traduction
# Conflicts:
#	web/src/i18n/locales/zh.json
2025-09-29 09:03:32 +02:00
dd
4c2979bb67 Merge branch 'QuantumNous:main' into main 2025-09-29 14:13:50 +08:00
CaIon
09e5e5d68c fix(http_client): improve error message for unsupported proxy schemes 2025-09-29 14:02:15 +08:00
Seefs
a91f3e7556 Merge pull request #1910 from seefs001/fix/volcengine_default_baseurl
alpha -> main
2025-09-29 12:30:24 +08:00
Seefs
bf9a5f5b52 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
7d49ce6da7 fix: set volcengine default url 2025-09-29 12:15:38 +08:00
Seefs
a2b5efb6bd 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
d916456801 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
9a1ef8b957 Merge branch 'main-upstream' into fix/volcengine_default_baseurl
# Conflicts:
#	main.go
2025-09-29 12:08:52 +08:00
comeback01
f71bf9e82f Merge remote-tracking branch 'upstream/main' into traduction 2025-09-28 12:23:17 +02:00
RedwindA
e2798fa62f fix(settings): ensure turnstile settings are reset when disabled 2025-09-28 17:38:56 +08:00
RedwindA
b08f1889e8 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
045ba23566 Merge pull request #1897 from QuantumNous/openrouter-enterprise
feat: 添加 openrouter-enterprise 支持
2025-09-28 15:31:01 +08:00
CaIon
7fe969c2ce fix: streamline error handling in OpenRouter response processing 2025-09-28 15:29:01 +08:00
CaIon
b91eb8a5ac 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
6e6a96d19f feat: enhance OpenRouter enterprise support with new settings and response handling 2025-09-28 15:23:27 +08:00
Calcium-Ion
f6be18eca4 Merge pull request #1819 from RixAPI/main
优化渠道测试
2025-09-28 14:31:29 +08:00
Calcium-Ion
bdefed7b0a Merge pull request #1811 from somnifex/main
refactor: 重构ollama渠道
2025-09-28 14:30:45 +08:00
JoeyLearnsToCode
3ed0ae83f1 Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit 2025-09-28 09:56:35 +08:00
creamlike1024
ee7ce5a476 feat: 添加 openrouter-enterprise 支持 2025-09-28 09:35:07 +08:00
Calcium-Ion
6659a8a569 Merge pull request #1836 from joesonshaw/main
fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
2025-09-28 01:17:22 +08:00
RedwindA
466d19c33d fix: add missing timeout 2025-09-27 22:29:27 +08:00
RedwindA
486c828df0 feat: 在添加和更新渠道时重置代理客户端缓存 2025-09-27 22:18:46 +08:00
RedwindA
c68fd36ee1 feat: 添加代理客户端缓存和重置功能,优化 HTTP 客户端管理 2025-09-27 22:18:39 +08:00
Seefs
74122e4175 Merge pull request #1886 from ShibaInu64/feature/volcengine-base-url
feat: volcengine支持自定义域名
2025-09-27 20:06:04 +08:00
huanghejian
2e4405e2bd Merge remote-tracking branch 'origin/main' into feature/volcengine-base-url 2025-09-27 19:53:42 +08:00
comeback01
79a252fc57 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]
72177c2c50 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]
3e941fd4fa 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]
25dfc0af22 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]
2a0ecf3a1f 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]
6736762713 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]
266f5784d7 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
923308a899 fix: 默认选择国内地址,不设置base url弹窗提示 2025-09-27 17:03:34 +08:00
huanghejian
e4efa34e6a pref: 防呆设计需要,不允许自定义,API地址优化为下拉选择 2025-09-27 16:40:18 +08:00
CaIon
143a2def24 feat: add startup logging with network IPs and container detection 2025-09-27 16:30:24 +08:00
Seefs
ffc077490c 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
476cf10495 feat: add startup logging with network IPs and container detection 2025-09-27 16:19:58 +08:00
Seefs
b294ff5e96 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
096141bfef Merge pull request #1888 from seefs001/feature/gemini-urlcontext
feat: gemini urlContext
2025-09-27 16:18:14 +08:00
Seefs
9e8b9995a6 feat: gemini urlContext 2025-09-27 16:16:34 +08:00
Seefs
a498da7ab2 Merge pull request #1887 from seefs001/fix/stripe
feat: allow stripe promotion code
2025-09-27 15:46:35 +08:00
Seefs
ad72500941 feat: allow stripe promotion code 2025-09-27 15:43:12 +08:00
Seefs
79859a3fc6 Merge pull request #1070 from wzxjohn/feature/umami
feat: support UMAMI analytics
2025-09-27 15:30:54 +08:00
Seefs
5197d874d7 Merge pull request #1853 from ZKunZhang/fix/playground-copy-newlines
fix: 修复多行代码复制换行丢失问题 & 优化API参数处理#1828
2025-09-27 15:25:44 +08:00
CaIon
e9e9708d1e feat: update release configuration to use new-api binaries for consistency 2025-09-27 15:24:40 +08:00
huanghejian
e0c6900195 pref: 优化代码 2025-09-27 15:19:54 +08:00
Calcium-Ion
bf99ead4a4 Merge pull request #1885 from RedwindA/fix/hide-unavailable-fetch-model-button
feat: `获取模型列表`按钮白名单
2025-09-27 15:12:01 +08:00
Calcium-Ion
474db61e56 Merge pull request #1884 from seefs001/fix/gemini
fix: add missing fields to Gemini request
2025-09-27 15:10:49 +08:00
CaIon
406be515db feat: rename output binaries to new-api for consistency across platforms 2025-09-27 15:04:06 +08:00
huanghejian
25a8473e85 feat: volcengine支持自定义域名 2025-09-27 09:35:03 +08:00
RedwindA
c25f487c8f feat: 添加对 Zhipu v4 渠道获取模型列表的支持 2025-09-27 01:19:43 +08:00
RedwindA
4f05c8eafb feat: 仅为适当的渠道渲染获取模型列表按钮 2025-09-27 01:19:09 +08:00
Seefs
f4d95bf1c4 fix: jsonRaw 2025-09-27 00:33:05 +08:00
Seefs
391d4514c0 fix: jsonRaw 2025-09-27 00:24:29 +08:00
Seefs
c89c8a7396 fix: add missing fields to Gemini request 2025-09-27 00:15:28 +08:00
Seefs
b69245212a Merge pull request #1769 from QAbot-zh/fix/account-status
fix:Account Management Status
2025-09-22 12:48:51 +08:00
HynoR
2a54e989b4 feat: add duplicate key removal function when edit or add new channel 2025-09-21 17:26:56 +09:00
Zhaokun Zhang
2ffdf738bd 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
joesonshaw
b4a6721948 Merge branch 'QuantumNous:main' into main 2025-09-19 23:31:43 +08:00
JoeyLearnsToCode
ec9903e640 feat: jump between section on channel edit page 2025-09-19 18:11:47 +08:00
wzxjohn
8d92ce38ed fix(relay): wrong key param while enable sse 2025-09-19 11:22:03 +08:00
joesonshaw
6c0b1681f9 fix(relay-xunfei): 修复讯飞渠道无法使用问题 #1740
将连接延迟关闭逻辑调整到协程中执行,防止在完全接收到所有数据前提前关闭
2025-09-19 10:49:47 +08:00
RixAPI
4b98773e9a 优化渠道测试
增加并发支持
2025-09-16 20:03:10 +08:00
wzxjohn
f2e9fd7afb fix(relay): wrong URL for claude model in GCP Vertex AI 2025-09-16 17:18:32 +08:00
somnifex
f19b5b8680 chore: 删除历史文件 2025-09-16 08:58:19 +08:00
somnifex
69a88a0563 feat: 添加ollama聊天流处理和非流处理功能 2025-09-16 08:58:06 +08:00
somnifex
1dd78b83b7 fix: 修复ollamaChatHandler中ReasoningContent字段的赋值逻辑 2025-09-16 08:54:34 +08:00
somnifex
62549717e0 fix: 添加对Thinking字段的处理逻辑,确保推理内容正确传递 2025-09-16 08:51:29 +08:00
somnifex
4eeca081fe fix: 修复图像URL处理逻辑,确保正确生成base64数据 2025-09-16 08:13:28 +08:00
somnifex
9d952e0d78 fix: 修复ollamaChatHandler中的FinishReason字段赋值逻辑 2025-09-15 23:43:39 +08:00
somnifex
f7d393fc72 refactor: 简化请求转换函数和流处理逻辑 2025-09-15 23:41:09 +08:00
somnifex
176fd6eda1 fix: 优化ollamaStreamHandler中的停止和最终使用响应逻辑 2025-09-15 23:23:53 +08:00
somnifex
7d6ba52d85 refactor: 更新请求转换逻辑,优化工具调用解析 2025-09-15 23:15:46 +08:00
somnifex
fc38c480a1 fix: 优化ollamaChatStreamChunk结构体字段格式 2025-09-15 23:09:10 +08:00
somnifex
51c4cd9ab5 feat: 重构ollama渠道请求 2025-09-15 23:01:14 +08:00
DD
274872b8e5 add submodel icon 2025-09-15 14:31:31 +08:00
DD
cab562276d Merge branch 'main' of github.com:danding5/new-api
# Conflicts:
#	relay/relay_adaptor.go
2025-09-15 14:31:06 +08:00
feitianbubu
465830945b fix: get video task err when Content-Type=json 2025-09-11 12:53:19 +08:00
DD
a12ed5709e merge 2025-09-10 19:11:58 +08:00
DD
78b0f8905b merge 2025-09-10 18:37:55 +08:00
DD
42d29756a0 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
undefinedcodezhong
99a8b5eef0 fix:Account Management Status 2025-09-10 10:41:44 +08:00
DD
23e4249ebe merge 2025-09-08 17:33:15 +08:00
DD
511489db09 add submodel.ai 2025-09-08 16:21:21 +08:00
feitianbubu
ef0780c096 feat: if video cannot play open in a new tab 2025-08-10 17:07:29 +08:00
wzxjohn
da98972dda feat: support UMAMI analytics 2025-05-16 16:44:47 +08:00
116 changed files with 9253 additions and 865 deletions

View File

@@ -38,21 +38,21 @@ jobs:
- 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
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-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
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 new-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
new-api
new-api-arm64
draft: true
generate_release_notes: true
env:

View File

@@ -39,12 +39,12 @@ jobs:
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
files: new-api-macos
draft: true
generate_release_notes: true
env:

View File

@@ -41,12 +41,12 @@ jobs:
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
files: new-api.exe
draft: true
generate_release_notes: true
env:

View File

@@ -1,5 +1,5 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong>
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a>
</p>
<div align="center">

216
README.fr.md Normal file
View File

@@ -0,0 +1,216 @@
<p align="right">
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong>
</p>
<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
<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="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>
</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.
<h2>🤝 Partenaires de confiance</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>Sans ordre particulier</strong></p>
<p align="center">
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p>&nbsp;</p>
## 📚 Documentation
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
Vous pouvez également accéder au DeepWiki généré par l'IA :
[![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Fonctionnalités clés
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
1. 🎨 Nouvelle interface utilisateur
2. 🌍 Prise en charge multilingue
3. 💰 Fonctionnalité de recharge en ligne (YiPay)
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible avec la base de données originale de One API
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
8. 📈 Tableau de bord des données (console)
9. 🔒 Regroupement de jetons et restrictions de modèles
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link
15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
1. Modèles de la série o d'OpenAI
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
2. Modèles de pensée de Claude
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
16. 🔄 Fonctionnalité de la pensée au contenu
17. 🔄 Limitation du débit du modèle pour les utilisateurs
18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
3. Canaux pris en charge :
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
## Prise en charge des modèles
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
7. Dify, ne prend actuellement en charge que chatflow
## Configuration des variables d'environnement
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE`
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
## Déploiement
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
> [!TIP]
> Dernière image Docker : `calciumion/new-api:latest`
### Considérations sur le déploiement multi-machines
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
### Exigences de déploiement
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
### Méthodes de déploiement
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
[Tutoriel avec des images](./docs/BT.md)
#### Utilisation de Docker Compose (recommandé)
```shell
# Télécharger le projet
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Modifier docker-compose.yml si nécessaire
# Démarrer
docker-compose up -d
```
#### Utilisation directe de l'image Docker
```shell
# Utilisation de SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
# Utilisation de MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
## Nouvelle tentative de canal et cache
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**.
### Méthode de configuration du cache
1. `REDIS_CONN_STRING` : Définir Redis comme cache
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
## Documentation de l'API
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
- [API de discussion](https://docs.newapi.pro/api/openai-chat)
- [API d'image](https://docs.newapi.pro/api/openai-image)
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat)
## Projets connexes
- [One API](https://github.com/songquanpeng/one-api) : Projet original
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
Autres projets basés sur New API :
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
## Aide et support
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🌟 Historique des étoiles
[![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)

View File

@@ -1,5 +1,5 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a>
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a>
</p>
<div align="center">

View File

@@ -67,6 +67,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeJimeng
case constant.ChannelTypeMoonshot:
apiType = constant.APITypeMoonshot
case constant.ChannelTypeSubmodel:
apiType = constant.APITypeSubmodel
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -23,6 +23,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
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 返回指定端点类型的默认信息以及是否存在

View File

@@ -2,9 +2,10 @@ package common
import (
"fmt"
"github.com/gin-gonic/gin"
"os"
"time"
"github.com/gin-gonic/gin"
)
func SysLog(s string) {
@@ -22,3 +23,33 @@ func FatalLog(v ...any) {
_, _ = 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")
}

View File

@@ -68,6 +68,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

View File

@@ -31,6 +31,7 @@ const (
APITypeXai
APITypeCoze
APITypeJimeng
APITypeMoonshot // this one is only for count, do not add any channel after this
APITypeDummy // this one is only for count, do not add any channel after this
APITypeMoonshot
APITypeSubmodel
APITypeDummy // this one is only for count, do not add any channel after this
)

View File

@@ -50,6 +50,8 @@ const (
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
ChannelTypeSubmodel = 53
ChannelTypeDoubaoVideo = 54
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -108,4 +110,6 @@ 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
}

View File

@@ -9,6 +9,7 @@ const (
EndpointTypeGemini EndpointType = "gemini"
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -38,7 +38,7 @@ 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{
@@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeDoubaoVideo {
return testResult{
localErr: errors.New("doubao video channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeVidu {
return testResult{
localErr: errors.New("vidu channel test is not supported"),
@@ -81,18 +87,26 @@ func testChannel(channel *model.Channel, testModel string) testResult {
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"
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
}
c.Request = &http.Request{
@@ -114,21 +128,6 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
// 重新检查模型类型并更新请求路径
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") ||
strings.Contains(testModel, "bge-") ||
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI {
requestPath = "/v1/embeddings"
c.Request.URL.Path = requestPath
}
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
c.Request.URL.Path = requestPath
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -153,17 +152,54 @@ func testChannel(channel *model.Channel, testModel string) testResult {
newAPIError: newAPIError,
}
}
request := buildTestRequest(testModel)
// Determine relay format based on request path
relayFormat := types.RelayFormatOpenAI
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
// 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
}
}
request := buildTestRequest(testModel, endpointType)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
if err != nil {
@@ -186,7 +222,8 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
testModel = info.UpstreamModelName
request.Model = testModel
// 更新请求中的模型名称
request.SetModelName(testModel)
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
@@ -216,33 +253,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,
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
// 创建一个 ImageRequest
prompt := "cat"
if request.Prompt != nil {
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
prompt = promptStr
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),
}
}
imageRequest := dto.ImageRequest{
Prompt: prompt,
Model: request.Model,
N: uint(request.N),
Size: request.Size,
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),
}
}
// 调用专门用于图像生成的转换函数
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
@@ -345,22 +411,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") {
@@ -373,12 +499,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
}
@@ -402,8 +522,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,
@@ -429,7 +550,6 @@ func TestChannel(c *gin.Context) {
"message": "",
"time": consumedTime,
})
return
}
var testAllChannelsLock sync.Mutex
@@ -463,7 +583,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()
@@ -477,7 +597,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
}
@@ -514,7 +634,6 @@ func TestAllChannels(c *gin.Context) {
"success": true,
"message": "",
})
return
}
var autoTestChannelsOnce sync.Once

View File

@@ -8,6 +8,7 @@ import (
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/service"
"strconv"
"strings"
@@ -188,6 +189,8 @@ func FetchUpstreamModels(c *gin.Context) {
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:
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}
@@ -381,18 +384,9 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 验证2FA后获取渠道密钥
// GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -400,24 +394,6 @@ func GetChannelKey(c *gin.Context) {
return
}
// 获取2FA记录并验证
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, fmt.Errorf("用户未启用2FA无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
@@ -433,10 +409,10 @@ func GetChannelKey(c *gin.Context) {
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 统一的成功响应格式
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"message": "获取成功",
"data": map[string]interface{}{
"key": channel.Key,
},
@@ -631,6 +607,7 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
service.ResetProxyClientCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -892,6 +869,7 @@ func UpdateChannel(c *gin.Context) {
return
}
model.InitChannelCache()
service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{

View File

@@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
@@ -94,6 +96,13 @@ func GetStatus(c *gin.Context) {
"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,
}

497
controller/passkey.go Normal file
View File

@@ -0,0 +1,497 @@
package controller
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-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
}

View File

@@ -0,0 +1,313 @@
package controller
import (
"fmt"
"net/http"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-api/setting/system_setting"
"time"
"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,
},
})
}

View File

@@ -13,6 +13,7 @@ import (
"one-api/relay"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/setting/ratio_setting"
"time"
)
@@ -120,6 +121,91 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
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 {
// 获取用户和组的倍率信息
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.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,分组倍率 %.2ftokens %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,分组倍率 %.2ftokens %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:
task.Status = model.TaskStatusFailure
task.Progress = "100%"

View File

@@ -65,7 +65,7 @@ func TelegramBind(c *gin.Context) {
return
}
c.Redirect(302, "/setting")
c.Redirect(302, "/console/personal")
}
func TelegramLogin(c *gin.Context) {

View File

@@ -225,7 +225,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
Quantity: stripe.Int64(amount),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
}
if "" == customerId {

View File

@@ -450,6 +450,10 @@ func GetSelf(c *gin.Context) {
"role": user.Role,
"status": user.Status,
"email": user.Email,
"github_id": user.GitHubId,
"oidc_id": user.OidcId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
"group": user.Group,
"quota": user.Quota,
"used_quota": user.UsedQuota,
@@ -1098,6 +1102,9 @@ type UpdateUserSettingRequest struct {
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"`
}
@@ -1113,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
}
// 验证预警类型
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
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": "无效的预警类型",
@@ -1188,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
}
}
// 如果是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 {
@@ -1221,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
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 {

View File

@@ -19,4 +19,15 @@ const (
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
if s == nil || s.OpenRouterEnterprise == nil {
return false
}
return *s.OpenRouterEnterprise
}

View File

@@ -195,11 +195,15 @@ type ClaudeRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
//ClaudeMetadata `json:"metadata,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools any `json:"tools,omitempty"`
ContextManagement json.RawMessage `json:"context_management,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -14,7 +14,30 @@ type GeminiChatRequest struct {
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolConfig *ToolConfig `json:"toolConfig,omitempty"`
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
CachedContent string `json:"cachedContent,omitempty"`
}
type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
}
type FunctionCallingConfig struct {
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
}
type FunctionCallingConfigMode string
type RetrievalConfig struct {
LatLng *LatLng `json:"latLng,omitempty"`
LanguageCode string `json:"languageCode,omitempty"`
}
type LatLng struct {
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -228,6 +251,7 @@ type GeminiChatTool struct {
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
CodeExecution any `json:"codeExecution,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
URLContext any `json:"urlContext,omitempty"`
}
type GeminiChatGenerationConfig struct {
@@ -239,12 +263,20 @@ type GeminiChatGenerationConfig struct {
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
ResponseSchema any `json:"responseSchema,omitempty"`
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
ResponseLogprobs bool `json:"responseLogprobs,omitempty"`
Logprobs *int32 `json:"logprobs,omitempty"`
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
Seed int64 `json:"seed,omitempty"`
ResponseModalities []string `json:"responseModalities,omitempty"`
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
}
type MediaResolution string
type GeminiChatCandidate struct {
Content GeminiChatContent `json:"content"`
FinishReason *string `json:"finishReason"`

View File

@@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct {
Dimensions int `json:"dimensions,omitempty"`
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
// 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户
// 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私
SafetyIdentifier string `json:"safety_identifier,omitempty"`
// Whether or not to store the output of this chat completion request for use in our model distillation or evals products.
// 是否存储此次请求数据供 OpenAI 用于评估和优化产品
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
Store json.RawMessage `json:"store,omitempty"`
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Prediction json.RawMessage `json:"prediction,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
@@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct {
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -7,6 +7,9 @@ type UserSetting struct {
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
NotifyTypeGotify = "gotify" // Gotify 推送
)

20
go.mod
View File

@@ -1,7 +1,9 @@
module one-api
// +heroku goVersion go1.18
go 1.23.4
go 1.24.0
toolchain go1.24.6
require (
github.com/Calcium-Ion/go-epay v0.0.4
@@ -20,6 +22,7 @@ require (
github.com/glebarez/sqlite v1.9.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-webauthn/webauthn v0.14.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
@@ -35,10 +38,10 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
golang.org/x/crypto v0.35.0
golang.org/x/crypto v0.42.0
golang.org/x/image v0.23.0
golang.org/x/net v0.35.0
golang.org/x/sync v0.11.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2
@@ -58,6 +61,7 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -65,8 +69,11 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
@@ -91,11 +98,12 @@ require (
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect

37
go.sum
View File

@@ -47,6 +47,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
@@ -89,16 +91,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
@@ -200,8 +210,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
@@ -229,27 +240,31 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -261,14 +276,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

31
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"embed"
"fmt"
"log"
@@ -16,6 +17,8 @@ import (
"one-api/setting/ratio_setting"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
@@ -33,6 +36,7 @@ var buildFS embed.FS
var indexPage []byte
func main() {
startTime := time.Now()
err := InitResources()
if err != nil {
@@ -145,11 +149,31 @@ func main() {
})
server.Use(sessions.Sessions("session", store))
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("UMAMI_WEBSITE_ID") != "" {
umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
if umamiScriptURL == "" {
umamiScriptURL = "https://analytics.umami.is/script.js"
}
analyticsInjectBuilder.WriteString("<script defer src=\"")
analyticsInjectBuilder.WriteString(umamiScriptURL)
analyticsInjectBuilder.WriteString("\" data-website-id=\"")
analyticsInjectBuilder.WriteString(umamiSiteID)
analyticsInjectBuilder.WriteString("\"></script>")
}
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
// Log startup success message
common.LogStartupSuccess(startTime, port)
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
@@ -161,8 +185,9 @@ func InitResources() error {
// This is a placeholder function for future resource initialization
err := godotenv.Load(".env")
if err != nil {
common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量")
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
if common.DebugEnabled {
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
}
}
// 加载环境变量
@@ -204,4 +229,4 @@ func InitResources() error {
return err
}
return nil
}
}

View File

@@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, errors.New("video无效的请求, " + err.Error())
}
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID

View File

@@ -0,0 +1,131 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
// SecureVerificationRequired 安全验证中间件
// 检查用户是否在有效时间内通过了安全验证
// 如果未验证或验证已过期,返回 401 错误
func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查用户是否已登录
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查 session 中的验证时间戳
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要安全验证",
"code": "VERIFICATION_REQUIRED",
})
c.Abort()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
"code": "VERIFICATION_INVALID",
})
c.Abort()
return
}
// 检查验证是否过期
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
"code": "VERIFICATION_EXPIRED",
})
c.Abort()
return
}
// 验证有效,继续处理请求
c.Next()
}
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
func OptionalSecureVerification() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.Set("secure_verified", false)
c.Next()
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.Set("secure_verified", false)
c.Next()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.Set("secure_verified", false)
c.Next()
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.Set("secure_verified", false)
c.Next()
return
}
c.Set("secure_verified", true)
c.Set("secure_verified_at", verifiedAt)
c.Next()
}
}
// ClearSecureVerification 清除安全验证状态
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
}

View File

@@ -251,6 +251,7 @@ func migrateDB() error {
&Channel{},
&Token{},
&User{},
&PasskeyCredential{},
&Option{},
&Redemption{},
&Ability{},
@@ -283,6 +284,7 @@ func migrateDBFast() error {
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
{&Ability{}, "Ability"},

View File

@@ -82,6 +82,7 @@ func InitOptionMap() {
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
common.OptionMap["StripePriceId"] = setting.StripePriceId
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -330,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
case "StripeMinTopUp":
setting.StripeMinTopUp, _ = strconv.Atoi(value)
case "StripePromotionCodesEnabled":
setting.StripePromotionCodesEnabled = value == "true"
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":

209
model/passkey.go Normal file
View File

@@ -0,0 +1,209 @@
package model
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"one-api/common"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
var (
ErrPasskeyNotFound = errors.New("passkey credential not found")
ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员")
)
type PasskeyCredential struct {
ID int `json:"id" gorm:"primaryKey"`
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
SignCount uint32 `json:"sign_count" gorm:"default:0"`
CloneWarning bool `json:"clone_warning"`
UserPresent bool `json:"user_present"`
UserVerified bool `json:"user_verified"`
BackupEligible bool `json:"backup_eligible"`
BackupState bool `json:"backup_state"`
Transports string `json:"transports" gorm:"type:text"`
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
if p == nil || strings.TrimSpace(p.Transports) == "" {
return nil
}
var transports []string
if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
return nil
}
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
for _, transport := range transports {
result = append(result, protocol.AuthenticatorTransport(transport))
}
return result
}
func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) {
if len(list) == 0 {
p.Transports = ""
return
}
stringList := make([]string, len(list))
for i, transport := range list {
stringList[i] = string(transport)
}
encoded, err := json.Marshal(stringList)
if err != nil {
return
}
p.Transports = string(encoded)
}
func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential {
flags := webauthn.CredentialFlags{
UserPresent: p.UserPresent,
UserVerified: p.UserVerified,
BackupEligible: p.BackupEligible,
BackupState: p.BackupState,
}
credID, _ := base64.StdEncoding.DecodeString(p.CredentialID)
pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey)
aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID)
return webauthn.Credential{
ID: credID,
PublicKey: pubKey,
AttestationType: p.AttestationType,
Transport: p.TransportList(),
Flags: flags,
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: p.SignCount,
CloneWarning: p.CloneWarning,
Attachment: protocol.AuthenticatorAttachment(p.Attachment),
},
}
}
func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential {
if credential == nil {
return nil
}
passkey := &PasskeyCredential{
UserID: userID,
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
AttestationType: credential.AttestationType,
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
SignCount: credential.Authenticator.SignCount,
CloneWarning: credential.Authenticator.CloneWarning,
UserPresent: credential.Flags.UserPresent,
UserVerified: credential.Flags.UserVerified,
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
Attachment: string(credential.Authenticator.Attachment),
}
passkey.SetTransports(credential.Transport)
return passkey
}
func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) {
if credential == nil || p == nil {
return
}
p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID)
p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey)
p.AttestationType = credential.AttestationType
p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID)
p.SignCount = credential.Authenticator.SignCount
p.CloneWarning = credential.Authenticator.CloneWarning
p.UserPresent = credential.Flags.UserPresent
p.UserVerified = credential.Flags.UserVerified
p.BackupEligible = credential.Flags.BackupEligible
p.BackupState = credential.Flags.BackupState
p.Attachment = string(credential.Authenticator.Attachment)
p.SetTransports(credential.Transport)
}
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
if userID == 0 {
common.SysLog("GetPasskeyByUserID: empty user ID")
return nil, ErrFriendlyPasskeyNotFound
}
var credential PasskeyCredential
if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志
return nil, ErrPasskeyNotFound
}
// 只有真正的数据库错误才记录日志
common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err))
return nil, ErrFriendlyPasskeyNotFound
}
return &credential, nil
}
func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
if len(credentialID) == 0 {
common.SysLog("GetPasskeyByCredentialID: empty credential ID")
return nil, ErrFriendlyPasskeyNotFound
}
credIDStr := base64.StdEncoding.EncodeToString(credentialID)
var credential PasskeyCredential
if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID)))
return nil, ErrFriendlyPasskeyNotFound
}
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err))
return nil, ErrFriendlyPasskeyNotFound
}
return &credential, nil
}
func UpsertPasskeyCredential(credential *PasskeyCredential) error {
if credential == nil {
common.SysLog("UpsertPasskeyCredential: nil credential provided")
return fmt.Errorf("Passkey 保存失败,请重试")
}
return DB.Transaction(func(tx *gorm.DB) error {
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
})
}
func DeletePasskeyByUserID(userID int) error {
if userID == 0 {
common.SysLog("DeletePasskeyByUserID: empty user ID")
return fmt.Errorf("删除失败,请重试")
}
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err))
return fmt.Errorf("删除失败,请重试")
}
return nil
}

View File

@@ -24,7 +24,7 @@ type Task struct {
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
CreatedAt int64 `json:"created_at" gorm:"index"`
UpdatedAt int64 `json:"updated_at"`
TaskID string `json:"task_id" gorm:"type:varchar(50);index"` // 第三方id不一定有/ song id\ Task id
TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id不一定有/ song id\ Task id
Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台
UserId int `json:"user_id" gorm:"index"`
ChannelId int `json:"channel_id" gorm:"index"`

View File

@@ -18,7 +18,7 @@ import (
// Otherwise, the sensitive information will be saved on local storage in plain text!
type User struct {
Id int `json:"id"`
Username string `json:"username" gorm:"unique;index" validate:"max=12"`
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`

View File

@@ -265,6 +265,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
resp, err := client.Do(req)
if err != nil {
logger.LogError(c, "do request failed: "+err.Error())
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
}
if resp == nil {

View File

@@ -7,7 +7,6 @@ import (
"one-api/dto"
"one-api/relay/channel/claude"
relaycommon "one-api/relay/common"
"one-api/setting/model_setting"
"one-api/types"
"github.com/gin-gonic/gin"
@@ -52,7 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
claude.CommonClaudeHeadersOperation(c, req, info)
return nil
}

View File

@@ -16,6 +16,7 @@ var awsModelIDMap = map[string]string{
"claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0",
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
"claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -69,6 +70,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"anthropic.claude-opus-4-1-20250805-v1:0": {
"us": true,
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
"us": true,
"ap": true,
"eu": true,
},
// Nova models - all support three major regions
"amazon.nova-micro-v1:0": {
"us": true,

View File

@@ -52,11 +52,25 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := ""
if a.RequestMode == RequestModeMessage {
return fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl), nil
baseURL = fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl)
} else {
return fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl), nil
baseURL = fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl)
}
if info.IsClaudeBetaQuery {
baseURL = baseURL + "?beta=true"
}
return baseURL, nil
}
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
// common headers operation
anthropicBeta := c.Request.Header.Get("anthropic-beta")
if anthropicBeta != "" {
req.Set("anthropic-beta", anthropicBeta)
}
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
@@ -67,7 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
anthropicVersion = "2023-06-01"
}
req.Set("anthropic-version", anthropicVersion)
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
CommonClaudeHeadersOperation(c, req, info)
return nil
}

View File

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

View File

@@ -245,6 +245,7 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
googleSearch := false
codeExecution := false
urlContext := false
for _, tool := range textRequest.Tools {
if tool.Function.Name == "googleSearch" {
googleSearch = true
@@ -254,6 +255,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
codeExecution = true
continue
}
if tool.Function.Name == "urlContext" {
urlContext = true
continue
}
if tool.Function.Parameters != nil {
params, ok := tool.Function.Parameters.(map[string]interface{})
@@ -281,6 +286,11 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
GoogleSearch: make(map[string]string),
})
}
if urlContext {
geminiTools = append(geminiTools, dto.GeminiChatTool{
URLContext: make(map[string]string),
})
}
if len(functions) > 0 {
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,

View File

@@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
request.EncodingFormat = ""
return request, nil
}

View File

@@ -10,6 +10,7 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
@@ -17,10 +18,7 @@ import (
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{}
@@ -31,32 +29,21 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
IncludeUsage: true,
}
return requestOpenAI2Ollama(c, openaiRequest.(*dto.GeneralOpenAIRequest))
// map to ollama chat request (Claude -> OpenAI -> Ollama chat)
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayFormat == types.RelayFormatClaude {
return info.ChannelBaseUrl + "/v1/chat/completions", nil
}
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
return info.ChannelBaseUrl + "/api/embed", nil
default:
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
return info.ChannelBaseUrl + "/api/chat", nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
@@ -66,10 +53,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
if request == nil { return nil, errors.New("request is nil") }
// decide generate or chat
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
return openAIToGenerate(c, request)
}
return requestOpenAI2Ollama(c, request)
return openAIChatToOllamaChat(c, request)
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
@@ -80,10 +69,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
@@ -92,15 +78,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
usage, err = ollamaEmbeddingHandler(c, info, resp)
return ollamaEmbeddingHandler(c, info, resp)
default:
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
return ollamaStreamHandler(c, info, resp)
}
return ollamaChatHandler(c, info, resp)
}
return
}
func (a *Adaptor) GetModelList() []string {

View File

@@ -2,48 +2,69 @@ package ollama
import (
"encoding/json"
"one-api/dto"
)
type OllamaRequest struct {
Model string `json:"model,omitempty"`
Messages []dto.Message `json:"messages,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Seed float64 `json:"seed,omitempty"`
Topp float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
Stop any `json:"stop,omitempty"`
MaxTokens uint `json:"max_tokens,omitempty"`
Tools []dto.ToolCallRequest `json:"tools,omitempty"`
ResponseFormat any `json:"response_format,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
Suffix any `json:"suffix,omitempty"`
StreamOptions *dto.StreamOptions `json:"stream_options,omitempty"`
Prompt any `json:"prompt,omitempty"`
Think json.RawMessage `json:"think,omitempty"`
type OllamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
Images []string `json:"images,omitempty"`
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
}
type Options struct {
Seed int `json:"seed,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopK int `json:"top_k,omitempty"`
TopP float64 `json:"top_p,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
NumPredict int `json:"num_predict,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
type OllamaToolFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters interface{} `json:"parameters,omitempty"`
}
type OllamaTool struct {
Type string `json:"type"`
Function OllamaToolFunction `json:"function"`
}
type OllamaToolCall struct {
Function struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"`
} `json:"function"`
}
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaChatMessage `json:"messages"`
Tools interface{} `json:"tools,omitempty"`
Format interface{} `json:"format,omitempty"`
Stream bool `json:"stream,omitempty"`
Options map[string]any `json:"options,omitempty"`
KeepAlive interface{} `json:"keep_alive,omitempty"`
Think json.RawMessage `json:"think,omitempty"`
}
type OllamaGenerateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
Suffix string `json:"suffix,omitempty"`
Images []string `json:"images,omitempty"`
Format interface{} `json:"format,omitempty"`
Stream bool `json:"stream,omitempty"`
Options map[string]any `json:"options,omitempty"`
KeepAlive interface{} `json:"keep_alive,omitempty"`
Think json.RawMessage `json:"think,omitempty"`
}
type OllamaEmbeddingRequest struct {
Model string `json:"model,omitempty"`
Input []string `json:"input"`
Options *Options `json:"options,omitempty"`
Model string `json:"model"`
Input interface{} `json:"input"`
Options map[string]any `json:"options,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
}
type OllamaEmbeddingResponse struct {
Error string `json:"error,omitempty"`
Model string `json:"model"`
Embedding [][]float64 `json:"embeddings,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model"`
Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}

View File

@@ -1,6 +1,7 @@
package ollama
import (
"encoding/json"
"fmt"
"io"
"net/http"
@@ -14,121 +15,176 @@ import (
"github.com/gin-gonic/gin"
)
func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*OllamaRequest, error) {
messages := make([]dto.Message, 0, len(request.Messages))
for _, message := range request.Messages {
if !message.IsStringContent() {
mediaMessages := message.ParseContent()
for j, mediaMessage := range mediaMessages {
if mediaMessage.Type == dto.ContentTypeImageURL {
imageUrl := mediaMessage.GetImageMedia()
// check if not base64
if strings.HasPrefix(imageUrl.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Ollama")
if err != nil {
return nil, err
func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
chatReq := &OllamaChatRequest{
Model: r.Model,
Stream: r.Stream,
Options: map[string]any{},
Think: r.Think,
}
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" {
chatReq.Format = "json"
} else if r.ResponseFormat.Type == "json_schema" {
if len(r.ResponseFormat.JsonSchema) > 0 {
var schema any
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
chatReq.Format = schema
}
}
}
// options mapping
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
if r.Stop != nil {
switch v := r.Stop.(type) {
case string:
chatReq.Options["stop"] = []string{v}
case []string:
chatReq.Options["stop"] = v
case []any:
arr := make([]string,0,len(v))
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
if len(arr)>0 { chatReq.Options["stop"] = arr }
}
}
if len(r.Tools) > 0 {
tools := make([]OllamaTool,0,len(r.Tools))
for _, t := range r.Tools {
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
}
chatReq.Tools = tools
}
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
for _, m := range r.Messages {
var textBuilder strings.Builder
var images []string
if m.IsStringContent() {
textBuilder.WriteString(m.StringContent())
} else {
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
var base64Data string
if strings.HasPrefix(img.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
if err != nil { return nil, err }
base64Data = fileData.Base64Data
} else if strings.HasPrefix(img.Url, "data:") {
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
} else {
base64Data = img.Url
}
imageUrl.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
if base64Data != "" { images = append(images, base64Data) }
}
mediaMessage.ImageUrl = imageUrl
mediaMessages[j] = mediaMessage
} else if part.Type == dto.ContentTypeText {
textBuilder.WriteString(part.Text)
}
}
message.SetMediaContent(mediaMessages)
}
messages = append(messages, dto.Message{
Role: message.Role,
Content: message.Content,
ToolCalls: message.ToolCalls,
ToolCallId: message.ToolCallId,
})
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
if len(images)>0 { cm.Images = images }
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
parsed := m.ParseToolCalls()
if len(parsed) > 0 {
calls := make([]OllamaToolCall,0,len(parsed))
for _, tc := range parsed {
var args interface{}
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
if args==nil { args = map[string]any{} }
oc := OllamaToolCall{}
oc.Function.Name = tc.Function.Name
oc.Function.Arguments = args
calls = append(calls, oc)
}
cm.ToolCalls = calls
}
}
chatReq.Messages = append(chatReq.Messages, cm)
}
str, ok := request.Stop.(string)
var Stop []string
if ok {
Stop = []string{str}
} else {
Stop, _ = request.Stop.([]string)
}
ollamaRequest := &OllamaRequest{
Model: request.Model,
Messages: messages,
Stream: request.Stream,
Temperature: request.Temperature,
Seed: request.Seed,
Topp: request.TopP,
TopK: request.TopK,
Stop: Stop,
Tools: request.Tools,
MaxTokens: request.GetMaxTokens(),
ResponseFormat: request.ResponseFormat,
FrequencyPenalty: request.FrequencyPenalty,
PresencePenalty: request.PresencePenalty,
Prompt: request.Prompt,
StreamOptions: request.StreamOptions,
Suffix: request.Suffix,
}
ollamaRequest.Think = request.Think
return ollamaRequest, nil
return chatReq, nil
}
func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest {
return &OllamaEmbeddingRequest{
Model: request.Model,
Input: request.ParseInput(),
Options: &Options{
Seed: int(request.Seed),
Temperature: request.Temperature,
TopP: request.TopP,
FrequencyPenalty: request.FrequencyPenalty,
PresencePenalty: request.PresencePenalty,
},
// openAIToGenerate converts OpenAI completions request to Ollama generate
func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) {
gen := &OllamaGenerateRequest{
Model: r.Model,
Stream: r.Stream,
Options: map[string]any{},
Think: r.Think,
}
// Prompt may be in r.Prompt (string or []any)
if r.Prompt != nil {
switch v := r.Prompt.(type) {
case string:
gen.Prompt = v
case []any:
var sb strings.Builder
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
gen.Prompt = sb.String()
default:
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
}
}
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
}
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
if r.Stop != nil {
switch v := r.Stop.(type) {
case string: gen.Options["stop"] = []string{v}
case []string: gen.Options["stop"] = v
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
}
}
return gen, nil
}
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
opts := map[string]any{}
if r.Temperature != nil { opts["temperature"] = r.Temperature }
if r.TopP != 0 { opts["top_p"] = r.TopP }
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
input := r.ParseInput()
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
}
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var ollamaEmbeddingResponse OllamaEmbeddingResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
var oResp OllamaEmbeddingResponse
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if ollamaEmbeddingResponse.Error != "" {
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding)
data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1)
data = append(data, dto.OpenAIEmbeddingResponseItem{
Embedding: flattenedEmbeddings,
Object: "embedding",
})
usage := &dto.Usage{
TotalTokens: info.PromptTokens,
CompletionTokens: 0,
PromptTokens: info.PromptTokens,
}
embeddingResponse := &dto.OpenAIEmbeddingResponse{
Object: "list",
Data: data,
Model: info.UpstreamModelName,
Usage: *usage,
}
doResponseBody, err := common.Marshal(embeddingResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
service.IOCopyBytesGracefully(c, resp, doResponseBody)
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
out, _ := common.Marshal(embResp)
service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}
func flattenEmbeddings(embeddings [][]float64) []float64 {
flattened := []float64{}
for _, row := range embeddings {
flattened = append(flattened, row...)
}
return flattened
}

View File

@@ -0,0 +1,210 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/logger"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type ollamaChatStreamChunk struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
// chat
Message *struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking json.RawMessage `json:"thinking"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
// generate
Response string `json:"response"`
Done bool `json:"done"`
DoneReason string `json:"done_reason"`
TotalDuration int64 `json:"total_duration"`
LoadDuration int64 `json:"load_duration"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
PromptEvalDuration int64 `json:"prompt_eval_duration"`
EvalDuration int64 `json:"eval_duration"`
}
func toUnix(ts string) int64 {
if ts == "" { return time.Now().Unix() }
// try time.RFC3339 or with nanoseconds
t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
return t.Unix()
}
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
defer service.CloseResponseBodyGracefully(resp)
helper.SetEventStreamHeaders(c)
scanner := bufio.NewScanner(resp.Body)
usage := &dto.Usage{}
var model = info.UpstreamModelName
var responseId = common.GetUUID()
var created = time.Now().Unix()
var toolCallIndex int
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if line == "" { continue }
var chunk ollamaChatStreamChunk
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chunk.Model != "" { model = chunk.Model }
created = toUnix(chunk.CreatedAt)
if !chunk.Done {
// delta content
var content string
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
delta := dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
Created: created,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
Index: 0,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
} },
}
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
raw := strings.TrimSpace(string(chunk.Message.Thinking))
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
}
// tool calls
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
for _, tc := range chunk.Message.ToolCalls {
// arguments -> string
argBytes, _ := json.Marshal(tc.Function.Arguments)
toolId := fmt.Sprintf("call_%d", toolCallIndex)
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
tr.SetIndex(toolCallIndex)
toolCallIndex++
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
}
}
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
continue
}
// done frame
// finalize once and break loop
usage.PromptTokens = chunk.PromptEvalCount
usage.CompletionTokens = chunk.EvalCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
finishReason := chunk.DoneReason
if finishReason == "" { finishReason = "stop" }
// emit stop delta
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
}
// emit usage frame
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
}
// send [DONE]
helper.Done(c)
break
}
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
return usage, nil
}
// non-stream handler for chat/generate
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
raw := string(body)
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
lines := strings.Split(raw, "\n")
var (
aggContent strings.Builder
reasoningBuilder strings.Builder
lastChunk ollamaChatStreamChunk
parsedAny bool
)
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" { continue }
var ck ollamaChatStreamChunk
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
continue
}
parsedAny = true
lastChunk = ck
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
raw := strings.TrimSpace(string(ck.Message.Thinking))
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
}
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
}
if !parsedAny {
var single ollamaChatStreamChunk
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
lastChunk = single
if single.Message != nil {
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
aggContent.WriteString(single.Message.Content)
} else { aggContent.WriteString(single.Response) }
}
model := lastChunk.Model
if model == "" { model = info.UpstreamModelName }
created := toUnix(lastChunk.CreatedAt)
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
content := aggContent.String()
finishReason := lastChunk.DoneReason
if finishReason == "" { finishReason = "stop" }
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
Object: "chat.completion",
Created: created,
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
Message: msg,
FinishReason: finishReason,
} },
Usage: *usage,
}
out, _ := common.Marshal(full)
service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}
func contentPtr(s string) *string { if s=="" { return nil }; return &s }

View File

@@ -12,6 +12,7 @@ import (
"one-api/constant"
"one-api/dto"
"one-api/logger"
"one-api/relay/channel/openrouter"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
@@ -185,10 +186,27 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if common.DebugEnabled {
println("upstream response body:", string(responseBody))
}
// Unmarshal to simpleResponse
if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() {
// 尝试解析为 openrouter enterprise
var enterpriseResponse openrouter.OpenRouterEnterpriseResponse
err = common.Unmarshal(responseBody, &enterpriseResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if enterpriseResponse.Success {
responseBody = enterpriseResponse.Data
} else {
logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
err = common.Unmarshal(responseBody, &simpleResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}

View File

@@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++
if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
webSearchTool.CallCount++
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
package openrouter
import "encoding/json"
type RequestReasoning struct {
// One of the following (not both):
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
@@ -7,3 +9,8 @@ type RequestReasoning struct {
// Optional: Default is false. All models support this.
Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response
}
type OpenRouterEnterpriseResponse struct {
Data json.RawMessage `json:"data"`
Success bool `json:"success"`
}

View File

@@ -0,0 +1,86 @@
package submodel
import (
"errors"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/types"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", "Bearer "+info.ApiKey)
return nil
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("submodel channel: endpoint not supported")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
}
return
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}

View File

@@ -0,0 +1,16 @@
package submodel
var ModelList = []string{
"NousResearch/Hermes-4-405B-FP8",
"Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"zai-org/GLM-4.5-FP8",
"openai/gpt-oss-120b",
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1",
"deepseek-ai/DeepSeek-V3-0324",
"deepseek-ai/DeepSeek-V3.1",
}
const ChannelName = "submodel"

View File

@@ -0,0 +1,248 @@
package doubao
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/constant"
"one-api/dto"
"one-api/model"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// ============================
// Request / Response structures
// ============================
type ContentItem struct {
Type string `json:"type"` // "text" or "image_url"
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
}
type ImageURL struct {
URL string `json:"url"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
}
type responsePayload struct {
ID string `json:"id"` // task_id
}
type responseTask struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Content struct {
VideoURL string `json:"video_url"`
} `json:"content"`
Seed int `json:"seed"`
Resolution string `json:"resolution"`
Duration int `json:"duration"`
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ============================
// Adaptor implementation
// ============================
type TaskAdaptor struct {
ChannelType int
apiKey string
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.ChannelBaseUrl
a.apiKey = info.ApiKey
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
v, exists := c.Get("task_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(relaycommon.TaskSubmitReq)
body, err := a.convertToRequestPayload(&req)
if err != nil {
return nil, errors.Wrap(err, "convert request payload failed")
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
_ = resp.Body.Close()
// Parse Doubao response
var dResp responsePayload
if err := json.Unmarshal(responseBody, &dResp); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if dResp.ID == "" {
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID})
return dResp.ID, responseBody, nil
}
// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID)
req, err := http.NewRequest(http.MethodGet, uri, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
return service.GetHttpClient().Do(req)
}
func (a *TaskAdaptor) GetModelList() []string {
return ModelList
}
func (a *TaskAdaptor) GetChannelName() string {
return ChannelName
}
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
Model: req.Model,
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
URL: imgURL,
},
})
}
}
// TODO: Add support for additional parameters from metadata
// such as ratio, duration, seed, etc.
// metadata := req.Metadata
// if metadata != nil {
// // Parse and apply metadata parameters
// }
return &r, nil
}
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
resTask := responseTask{}
if err := json.Unmarshal(respBody, &resTask); err != nil {
return nil, errors.Wrap(err, "unmarshal task result failed")
}
taskResult := relaycommon.TaskInfo{
Code: 0,
}
// Map Doubao status to internal status
switch resTask.Status {
case "pending", "queued":
taskResult.Status = model.TaskStatusQueued
taskResult.Progress = "10%"
case "processing":
taskResult.Status = model.TaskStatusInProgress
taskResult.Progress = "50%"
case "succeeded":
taskResult.Status = model.TaskStatusSuccess
taskResult.Progress = "100%"
taskResult.Url = resTask.Content.VideoURL
// 解析 usage 信息用于按倍率计费
taskResult.CompletionTokens = resTask.Usage.CompletionTokens
taskResult.TotalTokens = resTask.Usage.TotalTokens
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
taskResult.Progress = "30%"
}
return &taskResult, nil
}

View File

@@ -0,0 +1,9 @@
package doubao
var ModelList = []string{
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
}
var ChannelName = "doubao-video"

View File

@@ -37,6 +37,7 @@ var claudeModelMap = map[string]string{
"claude-sonnet-4-20250514": "claude-sonnet-4@20250514",
"claude-opus-4-20250514": "claude-opus-4@20250514",
"claude-opus-4-1-20250805": "claude-opus-4-1@20250805",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929",
}
const anthropicVersion = "vertex-2023-10-16"
@@ -90,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
}
a.AccountCredentials = *adc
if a.RequestMode == RequestModeLlama {
if a.RequestMode == RequestModeGemini {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeClaude {
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else if a.RequestMode == RequestModeLlama {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
region,
@@ -98,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
region,
), nil
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
adc.ProjectID,
modelName,
suffix,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
region,
adc.ProjectID,
region,
modelName,
suffix,
), nil
}
} else {
var keyPrefix string
if strings.HasSuffix(suffix, "?alt=sse") {
keyPrefix = "&"
} else {
keyPrefix = "?"
}
if region == "global" {
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
} else {
return fmt.Sprintf(
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s",
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
}
}
return "", errors.New("unsupported request mode")
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
@@ -187,7 +215,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("Authorization", "Bearer "+accessToken)
}
if a.AccountCredentials.ProjectID != "" {
if a.AccountCredentials.ProjectID != "" {
req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
}
return nil

View File

@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
channelconstant "one-api/constant"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
@@ -188,21 +189,35 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
// 支持自定义域名,如果未设置则使用默认域名
baseUrl := info.ChannelBaseUrl
if baseUrl == "" {
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
}
switch info.RelayFormat {
case types.RelayFormatClaude:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", info.ChannelBaseUrl), nil
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", info.ChannelBaseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", info.ChannelBaseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", info.ChannelBaseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", info.ChannelBaseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", info.ChannelBaseUrl), nil
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
default:
switch info.RelayMode {
case constant.RelayModeChatCompletions:
if strings.HasPrefix(info.UpstreamModelName, "bot") {
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
}
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
case constant.RelayModeImagesEdits:
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
}
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
}

View File

@@ -9,6 +9,11 @@ var ModelList = []string{
"Doubao-lite-4k",
"Doubao-embedding",
"doubao-seedream-4-0-250828",
"seedream-4-0-250828",
"doubao-seedance-1-0-pro-250528",
"seedance-1-0-pro-250528",
"doubao-seed-1-6-thinking-250715",
"seed-1-6-thinking-250715",
}
var ChannelName = "volcengine"

View File

@@ -207,10 +207,6 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
return nil, nil, err
}
defer func() {
conn.Close()
}()
data := requestOpenAI2Xunfei(textRequest, appId, domain)
err = conn.WriteJSON(data)
if err != nil {
@@ -220,6 +216,9 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
dataChan := make(chan XunfeiChatResponse)
stopChan := make(chan bool)
go func() {
defer func() {
conn.Close()
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {

View File

@@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for Claude API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -105,7 +105,8 @@ type RelayInfo struct {
UserQuota int
RelayFormat types.RelayFormat
SendResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
FinalPreConsumedQuota int // 最终预消耗的配额
IsClaudeBetaQuery bool // /v1/messages?beta=true
PriceData types.PriceData
@@ -279,6 +280,9 @@ func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo {
info.ClaudeConvertInfo = &ClaudeConvertInfo{
LastMessagesType: LastMessageTypeNone,
}
if c.Query("beta") == "true" {
info.IsClaudeBetaQuery = true
}
return info
}
@@ -496,10 +500,52 @@ func (t TaskSubmitReq) HasImage() bool {
}
type TaskInfo struct {
Code int `json:"code"`
TaskID string `json:"task_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Url string `json:"url,omitempty"`
Progress string `json:"progress,omitempty"`
Code int `json:"code"`
TaskID string `json:"task_id"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Url string `json:"url,omitempty"`
Progress string `json:"progress,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费
TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费
}
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
// service_tier: 服务层级字段可能导致额外计费OpenAI、Claude、Responses API 支持)
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) {
var data map[string]interface{}
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error())
return jsonData, nil
}
// 默认移除 service_tier除非明确允许避免额外计费风险
if !channelOtherSettings.AllowServiceTier {
if _, exists := data["service_tier"]; exists {
delete(data, "service_tier")
}
}
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
if channelOtherSettings.DisableStore {
if _, exists := data["store"]; exists {
delete(data, "store")
}
}
// 默认移除 safety_identifier除非明确允许保护用户隐私避免向 OpenAI 报告用户信息)
if !channelOtherSettings.AllowSafetyIdentifier {
if _, exists := data["safety_identifier"]; exists {
delete(data, "safety_identifier")
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveDisabledFields Marshal error :" + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}

View File

@@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -1,6 +1,7 @@
package relay
import (
"github.com/gin-gonic/gin"
"one-api/constant"
"one-api/relay/channel"
"one-api/relay/channel/ali"
@@ -24,6 +25,8 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
"one-api/relay/channel/submodel"
taskdoubao "one-api/relay/channel/task/doubao"
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
@@ -37,8 +40,6 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
"github.com/gin-gonic/gin"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -103,6 +104,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &jimeng.Adaptor{}
case constant.APITypeMoonshot:
return &moonshot.Adaptor{} // Moonshot uses Claude API
case constant.APITypeSubmodel:
return &submodel.Adaptor{}
}
return nil
}
@@ -132,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &taskvertex.TaskAdaptor{}
case constant.ChannelTypeVidu:
return &taskVidu.TaskAdaptor{}
case constant.ChannelTypeDoubaoVideo:
return &taskdoubao.TaskAdaptor{}
}
}
return nil

View File

@@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// remove disabled fields for OpenAI Responses API
jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride)

View File

@@ -40,11 +40,17 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout)
userRoute.GET("/epay/notify", controller.EpayNotify)
@@ -59,6 +65,12 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.PUT("/self", controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.GET("/token", controller.GenerateAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish)
selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin)
selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish)
selfRoute.DELETE("/passkey", controller.PasskeyDelete)
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
@@ -87,6 +99,7 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey)
// Admin 2FA routes
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
@@ -115,7 +128,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

View File

@@ -7,12 +7,17 @@ import (
"net/http"
"net/url"
"one-api/common"
"sync"
"time"
"golang.org/x/net/proxy"
)
var httpClient *http.Client
var (
httpClient *http.Client
proxyClientLock sync.Mutex
proxyClients = make(map[string]*http.Client)
)
func InitHttpClient() {
if common.RelayTimeout == 0 {
@@ -28,12 +33,31 @@ func GetHttpClient() *http.Client {
return httpClient
}
// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化
func ResetProxyClientCache() {
proxyClientLock.Lock()
defer proxyClientLock.Unlock()
for _, client := range proxyClients {
if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
transport.CloseIdleConnections()
}
}
proxyClients = make(map[string]*http.Client)
}
// NewProxyHttpClient 创建支持代理的 HTTP 客户端
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
if proxyURL == "" {
return http.DefaultClient, nil
}
proxyClientLock.Lock()
if client, ok := proxyClients[proxyURL]; ok {
proxyClientLock.Unlock()
return client, nil
}
proxyClientLock.Unlock()
parsedURL, err := url.Parse(proxyURL)
if err != nil {
return nil, err
@@ -41,11 +65,16 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
switch parsedURL.Scheme {
case "http", "https":
return &http.Client{
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(parsedURL),
},
}, nil
}
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
proxyClientLock.Lock()
proxyClients[proxyURL] = client
proxyClientLock.Unlock()
return client, nil
case "socks5", "socks5h":
// 获取认证信息
@@ -67,15 +96,20 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
return nil, err
}
return &http.Client{
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
},
},
}, nil
}
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
proxyClientLock.Lock()
proxyClients[proxyURL] = client
proxyClientLock.Unlock()
return client, nil
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
return nil, fmt.Errorf("unsupported proxy scheme: %s, must be http, https, socks5 or socks5h", parsedURL.Scheme)
}
}

177
service/passkey/service.go Normal file
View File

@@ -0,0 +1,177 @@
package passkey
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"one-api/common"
"one-api/setting/system_setting"
"github.com/go-webauthn/webauthn/protocol"
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
const (
RegistrationSessionKey = "passkey_registration_session"
LoginSessionKey = "passkey_login_session"
VerifySessionKey = "passkey_verify_session"
)
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
settings := system_setting.GetPasskeySettings()
if settings == nil {
return nil, errors.New("未找到 Passkey 设置")
}
displayName := strings.TrimSpace(settings.RPDisplayName)
if displayName == "" {
displayName = common.SystemName
}
origins, err := resolveOrigins(r, settings)
if err != nil {
return nil, err
}
rpID, err := resolveRPID(r, settings, origins)
if err != nil {
return nil, err
}
selection := protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.UserVerificationRequirement(settings.UserVerification),
}
if selection.UserVerification == "" {
selection.UserVerification = protocol.VerificationPreferred
}
if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" {
selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment)
}
config := &webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
RPOrigins: origins,
AuthenticatorSelection: selection,
Debug: common.DebugEnabled,
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: 2 * time.Minute,
TimeoutUVD: 2 * time.Minute,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: 2 * time.Minute,
TimeoutUVD: 2 * time.Minute,
},
},
}
return webauthn.New(config)
}
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
originsStr := strings.TrimSpace(settings.Origins)
if originsStr != "" {
originList := strings.Split(originsStr, ",")
origins := make([]string, 0, len(originList))
for _, origin := range originList {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") {
return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed)
}
origins = append(origins, trimmed)
}
if len(origins) == 0 {
// 如果配置了Origins但过滤后为空使用自动推导
goto autoDetect
}
return origins, nil
}
autoDetect:
scheme := detectScheme(r)
if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") {
return nil, fmt.Errorf("Passkey 仅支持 HTTPS当前访问: %s://%s请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host)
}
// 优先使用请求的完整Host包含端口
host := r.Host
// 如果无法从请求获取Host尝试从ServerAddress获取
if host == "" && system_setting.ServerAddress != "" {
if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" {
host = parsed.Host
if scheme == "" && parsed.Scheme != "" {
scheme = parsed.Scheme
}
}
}
if host == "" {
return nil, fmt.Errorf("无法确定 Passkey 的 Origin请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress)
}
if scheme == "" {
scheme = "https"
}
origin := fmt.Sprintf("%s://%s", scheme, host)
return []string{origin}, nil
}
func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) {
rpID := strings.TrimSpace(settings.RPID)
if rpID != "" {
return hostWithoutPort(rpID), nil
}
if len(origins) == 0 {
return "", errors.New("Passkey 未配置 Origin无法推导 RPID")
}
parsed, err := url.Parse(origins[0])
if err != nil {
return "", fmt.Errorf("无法解析 Passkey Origin: %w", err)
}
return hostWithoutPort(parsed.Host), nil
}
func hostWithoutPort(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return ""
}
if strings.Contains(host, ":") {
if host, _, err := net.SplitHostPort(host); err == nil {
return host
}
}
return host
}
func detectScheme(r *http.Request) string {
if r == nil {
return ""
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
parts := strings.Split(proto, ",")
return strings.ToLower(strings.TrimSpace(parts[0]))
}
if r.TLS != nil {
return "https"
}
if r.URL != nil && r.URL.Scheme != "" {
return strings.ToLower(r.URL.Scheme)
}
if r.Header.Get("X-Forwarded-Protocol") != "" {
return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol")))
}
return "http"
}

View File

@@ -0,0 +1,50 @@
package passkey
import (
"encoding/json"
"errors"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error {
session := sessions.Default(c)
if data == nil {
session.Delete(key)
return session.Save()
}
payload, err := json.Marshal(data)
if err != nil {
return err
}
session.Set(key, string(payload))
return session.Save()
}
func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) {
session := sessions.Default(c)
raw := session.Get(key)
if raw == nil {
return nil, errSessionNotFound
}
session.Delete(key)
_ = session.Save()
var data webauthn.SessionData
switch value := raw.(type) {
case string:
if err := json.Unmarshal([]byte(value), &data); err != nil {
return nil, err
}
case []byte:
if err := json.Unmarshal(value, &data); err != nil {
return nil, err
}
default:
return nil, errors.New("Passkey 会话格式无效")
}
return &data, nil
}

71
service/passkey/user.go Normal file
View File

@@ -0,0 +1,71 @@
package passkey
import (
"fmt"
"strconv"
"strings"
"one-api/model"
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnUser struct {
user *model.User
credential *model.PasskeyCredential
}
func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser {
return &WebAuthnUser{user: user, credential: credential}
}
func (u *WebAuthnUser) WebAuthnID() []byte {
if u == nil || u.user == nil {
return nil
}
return []byte(strconv.Itoa(u.user.Id))
}
func (u *WebAuthnUser) WebAuthnName() string {
if u == nil || u.user == nil {
return ""
}
name := strings.TrimSpace(u.user.Username)
if name == "" {
return fmt.Sprintf("user-%d", u.user.Id)
}
return name
}
func (u *WebAuthnUser) WebAuthnDisplayName() string {
if u == nil || u.user == nil {
return ""
}
display := strings.TrimSpace(u.user.DisplayName)
if display != "" {
return display
}
return u.WebAuthnName()
}
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
if u == nil || u.credential == nil {
return nil
}
cred := u.credential.ToWebAuthnCredential()
return []webauthn.Credential{cred}
}
func (u *WebAuthnUser) ModelUser() *model.User {
if u == nil {
return nil
}
return u.user
}
func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential {
if u == nil {
return nil
}
return u.credential
}

View File

@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
// 默认内容格式适用于Email和Webhook
// 默认内容格式适用于Email和Webhook支持HTML
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}

View File

@@ -1,6 +1,8 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
switch notifyType {
case dto.NotifyTypeEmail:
// check setting email
userEmail = userSetting.NotificationEmail
if userEmail == "" {
// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
emailToUse := userSetting.NotificationEmail
if emailToUse == "" {
emailToUse = userEmail
}
if emailToUse == "" {
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
return nil
}
return sendEmailNotify(userEmail, data)
return sendEmailNotify(emailToUse, data)
case dto.NotifyTypeWebhook:
webhookURLStr := userSetting.WebhookUrl
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
return nil
}
return sendBarkNotify(barkURL, data)
case dto.NotifyTypeGotify:
gotifyUrl := userSetting.GotifyUrl
gotifyToken := userSetting.GotifyToken
if gotifyUrl == "" || gotifyToken == "" {
common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
return nil
}
return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
}
return nil
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return nil
}
func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
// 处理占位符
content := data.Content
for _, value := range data.Values {
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
}
// 构建完整的 Gotify API URL
// 确保 URL 以 /message 结尾
finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
// Gotify优先级范围0-10如果超出范围则使用默认值5
if priority < 0 || priority > 10 {
priority = 5
}
// 构建 JSON payload
type GotifyMessage struct {
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
}
payload := GotifyMessage{
Title: data.Title,
Message: content,
Priority: priority,
}
// 序列化为 JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal gotify payload: %v", err)
}
var req *http.Request
var resp *http.Response
if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "OneAPI-Gotify-Notify/1.0",
},
Body: payloadBytes,
}
resp, err = DoWorkerRequest(workerReq)
if err != nil {
return fmt.Errorf("failed to send gotify request through worker: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
} else {
// SSRF防护验证Gotify URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
// 直接发送请求
req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create gotify request: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
// 发送请求
client := GetHttpClient()
resp, err = client.Do(req)
if err != nil {
return fmt.Errorf("failed to send gotify request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
}
}
return nil
}

View File

@@ -29,6 +29,7 @@ const (
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
GeminiRoboticsER15InputAudioPrice = 1.00
)
const (
@@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
return GeminiRoboticsER15InputAudioPrice
}
return 0
}

View File

@@ -5,3 +5,4 @@ var StripeWebhookSecret = ""
var StripePriceId = ""
var StripeUnitPrice = 8.0
var StripeMinTopUp = 1
var StripePromotionCodesEnabled = false

View File

@@ -52,6 +52,8 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-20250514-thinking": 0.1,
"claude-opus-4-1-20250805": 0.1,
"claude-opus-4-1-20250805-thinking": 0.1,
"claude-sonnet-4-5-20250929": 0.1,
"claude-sonnet-4-5-20250929-thinking": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -69,6 +71,8 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-20250514-thinking": 1.25,
"claude-opus-4-1-20250805": 1.25,
"claude-opus-4-1-20250805-thinking": 1.25,
"claude-sonnet-4-5-20250929": 1.25,
"claude-sonnet-4-5-20250929-thinking": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}

View File

@@ -141,6 +141,7 @@ var defaultModelRatio = map[string]float64{
"claude-3-7-sonnet-20250219": 1.5,
"claude-3-7-sonnet-20250219-thinking": 1.5,
"claude-sonnet-4-20250514": 1.5,
"claude-sonnet-4-5-20250929": 1.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
@@ -178,6 +179,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-robotics-er-1.5-preview": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
@@ -251,6 +253,17 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"grok-3-mini-fast-beta": 0.3,
// submodel
"NousResearch/Hermes-4-405B-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
"Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
"zai-org/GLM-4.5-FP8": 0.8,
"openai/gpt-oss-120b": 0.5,
"deepseek-ai/DeepSeek-R1-0528": 0.8,
"deepseek-ai/DeepSeek-R1": 0.8,
"deepseek-ai/DeepSeek-V3-0324": 0.8,
"deepseek-ai/DeepSeek-V3.1": 0.8,
}
var defaultModelPrice = map[string]float64{
@@ -501,7 +514,6 @@ func GetCompletionRatio(name string) float64 {
}
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
lowercaseName := strings.ToLower(name)
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
if isReservedModel {
@@ -576,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
return 4, false
}
return 2.5 / 0.3, false
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
return 2.5 / 0.3, false
}
return 4, false
}
@@ -594,9 +608,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
}
// hint 只给官方上4倍率由于开源模型供应商自行定价不对其进行补全倍率进行强制对齐
if lowercaseName == "deepseek-chat" || lowercaseName == "deepseek-reasoner" {
return 4, true
}
if strings.HasPrefix(name, "ERNIE-Speed-") {
return 2, true
} else if strings.HasPrefix(name, "ERNIE-Lite-") {

View File

@@ -0,0 +1,49 @@
package system_setting
import (
"net/url"
"one-api/common"
"one-api/setting/config"
"strings"
)
type PasskeySettings struct {
Enabled bool `json:"enabled"`
RPDisplayName string `json:"rp_display_name"`
RPID string `json:"rp_id"`
Origins string `json:"origins"`
AllowInsecureOrigin bool `json:"allow_insecure_origin"`
UserVerification string `json:"user_verification"`
AttachmentPreference string `json:"attachment_preference"`
}
var defaultPasskeySettings = PasskeySettings{
Enabled: false,
RPDisplayName: common.SystemName,
RPID: "",
Origins: "",
AllowInsecureOrigin: false,
UserVerification: "preferred",
AttachmentPreference: "",
}
func init() {
config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
}
func GetPasskeySettings() *PasskeySettings {
if defaultPasskeySettings.RPID == "" && ServerAddress != "" {
// 从ServerAddress提取域名作为RPID
// ServerAddress可能是 "https://newapi.pro" 这种格式
serverAddr := strings.TrimSpace(ServerAddress)
if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
defaultPasskeySettings.RPID = parsed.Host
} else {
defaultPasskeySettings.RPID = serverAddr
}
}
if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" {
defaultPasskeySettings.Origins = ServerAddress
}
return &defaultPasskeySettings
}

View File

@@ -10,6 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
/>
<title>New API</title>
<analytics></analytics>
</head>
<body>

View File

@@ -32,6 +32,9 @@ import {
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
@@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
import OIDCIcon from '../common/logo/OIDCIcon';
import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
@@ -74,6 +77,8 @@ const LoginForm = () => {
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const logo = getLogo();
const systemName = getSystemName();
@@ -95,6 +100,12 @@ const LoginForm = () => {
}
}, [status]);
useEffect(() => {
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
@@ -266,6 +277,55 @@ const LoginForm = () => {
setEmailLoginLoading(false);
};
const handlePasskeyLogin = async () => {
if (!passkeySupported) {
showInfo('当前环境无法使用 Passkey 登录');
return;
}
if (!window.PublicKeyCredential) {
showInfo('当前浏览器不支持 Passkey');
return;
}
setPasskeyLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/login/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || '无法发起 Passkey 登录');
return;
}
const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
const payload = buildAssertionResult(assertion);
if (!payload) {
showError('Passkey 验证失败,请重试');
return;
}
const finishRes = await API.post('/api/user/passkey/login/finish', payload);
const finish = finishRes.data;
if (finish.success) {
userDispatch({ type: 'login', payload: finish.data });
setUserData(finish.data);
updateAPI();
showSuccess('登录成功!');
navigate('/console');
} else {
showError(finish.message || 'Passkey 登录失败,请重试');
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo('已取消 Passkey 登录');
} else {
showError('Passkey 登录失败,请重试');
}
} finally {
setPasskeyLoading(false);
}
};
// 包装的重置密码点击处理
const handleResetPasswordClick = () => {
setResetPasswordLoading(true);
@@ -385,6 +445,19 @@ const LoginForm = () => {
</div>
)}
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
@@ -437,6 +510,18 @@ const LoginForm = () => {
</Title>
</div>
<div className='px-2 py-8'>
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
type='tertiary'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Form className='space-y-3'>
<Form.Input
field='username'

View File

@@ -0,0 +1,117 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Modal } from '@douyinfe/semi-ui';
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../services/secureVerification';
import SecureVerificationModal from '../modals/SecureVerificationModal';
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
/**
* 渠道密钥查看组件使用示例
* 展示如何使用通用安全验证系统
*/
const ChannelKeyViewExample = ({ channelId }) => {
const { t } = useTranslation();
const [keyData, setKeyData] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
startVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后处理结果
if (result.success && result.data?.key) {
setKeyData(result.data.key);
setShowKeyModal(true);
}
},
successMessage: t('密钥获取成功'),
});
// 开始查看密钥流程
const handleViewKey = async () => {
const apiCall = createApiCalls.viewChannelKey(channelId);
await startVerification(apiCall, {
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 可以指定首选验证方式
});
};
return (
<>
{/* 查看密钥按钮 */}
<Button
type='primary'
theme='outline'
onClick={handleViewKey}
>
{t('查看密钥')}
</Button>
{/* 安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 密钥显示模态框 */}
<Modal
title={t('渠道密钥信息')}
visible={showKeyModal}
onCancel={() => setShowKeyModal(false)}
footer={
<Button type='primary' onClick={() => setShowKeyModal(false)}>
{t('完成')}
</Button>
}
width={700}
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}
/>
</Modal>
</>
);
};
export default ChannelKeyViewExample;

View File

@@ -181,8 +181,8 @@ export function PreCode(props) {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
const code =
ref.current.querySelector('code')?.innerText ?? '';
const codeElement = ref.current.querySelector('code');
const code = codeElement?.textContent ?? '';
copy(code).then((success) => {
if (success) {
Toast.success(t('代码已复制到剪贴板'));

View File

@@ -0,0 +1,285 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
/**
* 通用安全验证模态框组件
* 配合 useSecureVerification Hook 使用
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {Object} props.verificationMethods - 可用的验证方式
* @param {Object} props.verificationState - 当前验证状态
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onMethodSwitch - 验证方式切换回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
*/
const SecureVerificationModal = ({
visible,
verificationMethods,
verificationState,
onVerify,
onCancel,
onCodeChange,
onMethodSwitch,
title,
description,
}) => {
const { t } = useTranslation();
const [isAnimating, setIsAnimating] = useState(false);
const [verifySuccess, setVerifySuccess] = useState(false);
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
const { method, loading, code } = verificationState;
useEffect(() => {
if (visible) {
setIsAnimating(true);
setVerifySuccess(false);
} else {
setIsAnimating(false);
}
}, [visible]);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
onVerify(method, code);
}
if (e.key === 'Escape' && !loading) {
onCancel();
}
};
// 如果用户没有启用任何验证方式
if (visible && !has2FA && !hasPasskey) {
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={onCancel}
footer={
<Button onClick={onCancel}>{t('确定')}</Button>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='text-center py-6'>
<div className='mb-4'>
<svg
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Title heading={4} className='mb-2'>
{t('需要安全验证')}
</Typography.Title>
<Typography.Text type='tertiary'>
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
</Typography.Text>
<br />
<Typography.Text type='tertiary'>
{t('请前往个人设置 → 安全设置进行配置。')}
</Typography.Text>
</div>
</Modal>
);
}
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={loading ? undefined : onCancel}
closeOnEsc={!loading}
footer={null}
width={460}
centered
style={{
maxWidth: 'calc(100vw - 32px)'
}}
bodyStyle={{
padding: '20px 24px'
}}
>
<div style={{ width: '100%' }}>
{/* 描述信息 */}
{description && (
<Typography.Paragraph
type="tertiary"
style={{
margin: '0 0 20px 0',
fontSize: '14px',
lineHeight: '1.6'
}}
>
{description}
</Typography.Paragraph>
)}
{/* 验证方式选择 */}
<Tabs
activeKey={method}
onChange={onMethodSwitch}
type='line'
size='default'
style={{ margin: 0 }}
>
{has2FA && (
<TabPane
tab={t('两步验证')}
itemKey='2fa'
>
<div style={{ paddingTop: '20px' }}>
<div style={{ marginBottom: '12px' }}>
<Input
placeholder={t('请输入6位验证码或8位备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus={method === '2fa'}
disabled={loading}
prefix={
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
</svg>
}
style={{ width: '100%' }}
/>
</div>
<Typography.Text
type="tertiary"
size="small"
style={{
display: 'block',
marginBottom: '20px',
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('从认证器应用中获取验证码,或使用备用码')}
</Typography.Text>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div>
</TabPane>
)}
{hasPasskey && passkeySupported && (
<TabPane
tab={t('Passkey')}
itemKey='passkey'
>
<div style={{ paddingTop: '20px' }}>
<div style={{
textAlign: 'center',
padding: '24px 16px',
marginBottom: '20px'
}}>
<div style={{
width: 56,
height: 56,
margin: '0 auto 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
background: 'var(--semi-color-primary-light-default)',
}}>
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
</svg>
</div>
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
{t('使用 Passkey 验证')}
</Typography.Title>
<Typography.Text
type='tertiary'
style={{
display: 'block',
margin: 0,
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('点击验证按钮,使用您的生物特征或安全密钥')}
</Typography.Text>
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{t('验证 Passkey')}
</Button>
</div>
</div>
</TabPane>
)}
</Tabs>
</div>
</Modal>
);
};
export default SecureVerificationModal;

View File

@@ -58,7 +58,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
loading: sidebarLoading,
} = useSidebar();
const showSkeleton = useMinimumLoadingTime(sidebarLoading);
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [chatItems, setChatItems] = useState([]);

View File

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
import { Languages } from 'lucide-react';
import { CN, GB } from 'country-flag-icons/react/3x2';
import { CN, GB, FR } from 'country-flag-icons/react/3x2';
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
return (
@@ -42,12 +42,19 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
<GB title='English' className='!w-5 !h-auto' />
<span>English</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('fr')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<FR title='Français' className='!w-5 !h-auto' />
<span>Français</span>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
icon={<Languages size={18} />}
aria-label={t('切换语言')}
aria-label={t('common.changeLanguage')}
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'

View File

@@ -45,6 +45,7 @@ const PaymentSetting = () => {
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
});
let [loading, setLoading] = useState(false);

View File

@@ -19,7 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
import {
API,
copy,
showError,
showInfo,
showSuccess,
setStatusData,
prepareCredentialCreationOptions,
buildRegistrationResult,
isPasskeySupported,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
@@ -59,6 +70,10 @@ const PersonalSetting = () => {
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [systemToken, setSystemToken] = useState('');
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
warningThreshold: 100000,
@@ -66,23 +81,52 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
let saved = localStorage.getItem('status');
if (saved) {
const parsed = JSON.parse(saved);
setStatus(parsed);
if (parsed.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
setTurnstileSiteKey(parsed.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
getUserData().then((res) => {
console.log(userState);
});
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
(async () => {
try {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success && data) {
setStatus(data);
setStatusData(data);
if (data.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(data.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
} catch (e) {
// ignore and keep local status
}
})();
getUserData();
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
@@ -108,6 +152,12 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
gotifyUrl: settings.gotify_url || '',
gotifyToken: settings.gotify_token || '',
gotifyPriority:
settings.gotify_priority !== undefined
? settings.gotify_priority
: 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -131,11 +181,90 @@ const PersonalSetting = () => {
}
};
const loadPasskeyStatus = async () => {
try {
const res = await API.get('/api/user/passkey');
const { success, data, message } = res.data;
if (success) {
setPasskeyStatus({
enabled: data?.enabled || false,
last_used_at: data?.last_used_at || null,
backup_eligible: data?.backup_eligible || false,
backup_state: data?.backup_state || false,
});
} else {
showError(message);
}
} catch (error) {
// 忽略错误,保留默认状态
}
};
const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
setPasskeyRegisterLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/register/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || t('无法发起 Passkey 注册'));
return;
}
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
const credential = await navigator.credentials.create({ publicKey });
const payload = buildRegistrationResult(credential);
if (!payload) {
showError(t('Passkey 注册失败,请重试'));
return;
}
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
if (finishRes.data.success) {
showSuccess(t('Passkey 注册成功'));
await loadPasskeyStatus();
} else {
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo(t('已取消 Passkey 注册'));
} else {
showError(t('Passkey 注册失败,请重试'));
}
} finally {
setPasskeyRegisterLoading(false);
}
};
const handleRemovePasskey = async () => {
setPasskeyDeleteLoading(true);
try {
const res = await API.delete('/api/user/passkey');
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已解绑'));
await loadPasskeyStatus();
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
} finally {
setPasskeyDeleteLoading(false);
}
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
setUserData(data);
await loadPasskeyStatus();
} else {
showError(message);
}
@@ -286,6 +415,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
gotify_url: notificationSettings.gotifyUrl,
gotify_token: notificationSettings.gotifyToken,
gotify_priority: (() => {
const parsed = parseInt(notificationSettings.gotifyPriority);
return isNaN(parsed) ? 5 : parsed;
})(),
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
@@ -323,6 +458,12 @@ const PersonalSetting = () => {
handleSystemTokenClick={handleSystemTokenClick}
setShowChangePasswordModal={setShowChangePasswordModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
passkeyStatus={passkeyStatus}
passkeySupported={passkeySupported}
passkeyRegisterLoading={passkeyRegisterLoading}
passkeyDeleteLoading={passkeyDeleteLoading}
onPasskeyRegister={handleRegisterPasskey}
onPasskeyDelete={handleRemovePasskey}
/>
{/* 右侧:其他设置 */}

View File

@@ -30,6 +30,7 @@ import {
Spin,
Card,
Radio,
Select,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -77,6 +78,13 @@ const SystemSetting = () => {
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
'passkey.enabled': '',
'passkey.rp_display_name': '',
'passkey.rp_id': '',
'passkey.origins': [],
'passkey.allow_insecure_origin': '',
'passkey.user_verification': 'preferred',
'passkey.attachment_preference': '',
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
@@ -173,9 +181,25 @@ const SystemSetting = () => {
case 'SMTPSSLEnabled':
case 'LinuxDOOAuthEnabled':
case 'oidc.enabled':
case 'passkey.enabled':
case 'passkey.allow_insecure_origin':
case 'WorkerAllowHttpImageRequestEnabled':
item.value = toBoolean(item.value);
break;
case 'passkey.origins':
// origins是逗号分隔的字符串直接使用
item.value = item.value || '';
break;
case 'passkey.rp_display_name':
case 'passkey.rp_id':
case 'passkey.attachment_preference':
// 确保字符串字段不为null/undefined
item.value = item.value || '';
break;
case 'passkey.user_verification':
// 确保有默认值
item.value = item.value || 'preferred';
break;
case 'Price':
case 'MinTopUp':
item.value = parseFloat(item.value);
@@ -582,6 +606,36 @@ const SystemSetting = () => {
}
};
const submitPasskeySettings = async () => {
// 使用formApi直接获取当前表单值
const formValues = formApiRef.current?.getValues() || {};
const options = [];
options.push({
key: 'passkey.rp_display_name',
value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
});
options.push({
key: 'passkey.rp_id',
value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
});
options.push({
key: 'passkey.user_verification',
value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
});
options.push({
key: 'passkey.attachment_preference',
value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
});
options.push({
key: 'passkey.origins',
value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
});
await updateOptions(options);
};
const handleCheckboxChange = async (optionKey, event) => {
const value = event.target.checked;
@@ -957,6 +1011,116 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 Passkey')}>
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
<Banner
type='info'
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field="['passkey.enabled']"
noLabel
onChange={(e) =>
handleCheckboxChange('passkey.enabled', e)
}
>
{t('允许通过 Passkey 登录 & 认证')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['passkey.rp_display_name']"
label={t('服务显示名称')}
placeholder={t('默认使用系统名称')}
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['passkey.rp_id']"
label={t('网站域名标识')}
placeholder={t('例如example.com')}
extraText={t('留空则默认使用服务器地址注意不能携带http://或者https://')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field="['passkey.user_verification']"
label={t('安全验证级别')}
placeholder={t('是否要求指纹/面容等生物识别')}
optionList={[
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
{ label: t('强制要求'), value: 'required' },
{ label: t('不建议使用'), value: 'discouraged' },
]}
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field="['passkey.attachment_preference']"
label={t('设备类型偏好')}
placeholder={t('选择支持的认证设备类型')}
optionList={[
{ label: t('不限制'), value: '' },
{ label: t('本设备内置'), value: 'platform' },
{ label: t('外接设备'), value: 'cross-platform' },
]}
extraText={t('本设备:手机指纹/面容外接USB安全密钥')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field="['passkey.allow_insecure_origin']"
noLabel
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
onChange={(e) =>
handleCheckboxChange('passkey.allow_insecure_origin', e)
}
>
{t('允许不安全的 OriginHTTP')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Input
field="['passkey.origins']"
label={t('允许的 Origins')}
placeholder={t('填写带https的域名逗号分隔')}
extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[]需使用https')}
/>
</Col>
</Row>
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
{t('保存 Passkey 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

View File

@@ -28,6 +28,7 @@ import {
Tabs,
TabPane,
Popover,
Modal,
} from '@douyinfe/semi-ui';
import {
IconMail,
@@ -58,6 +59,12 @@ const AccountManagement = ({
handleSystemTokenClick,
setShowChangePasswordModal,
setShowAccountDeleteModal,
passkeyStatus,
passkeySupported,
passkeyRegisterLoading,
passkeyDeleteLoading,
onPasskeyRegister,
onPasskeyDelete,
}) => {
const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') {
@@ -83,6 +90,13 @@ const AccountManagement = ({
</Popover>
);
};
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString()
: t('尚未使用');
return (
<Card className='!rounded-2xl'>
{/* 卡片头部 */}
@@ -142,7 +156,7 @@ const AccountManagement = ({
size='small'
onClick={() => setShowEmailBindModal(true)}
>
{userState.user && userState.user.email !== ''
{isBound(userState.user?.email)
? t('修改绑定')
: t('绑定')}
</Button>
@@ -165,9 +179,11 @@ const AccountManagement = ({
{t('微信')}
</div>
<div className='text-sm text-gray-500 truncate'>
{userState.user && userState.user.wechat_id !== ''
? t('已绑定')
: t('未绑定')}
{!status.wechat_login
? t('未启用')
: isBound(userState.user?.wechat_id)
? t('已绑定')
: t('未绑定')}
</div>
</div>
</div>
@@ -179,7 +195,7 @@ const AccountManagement = ({
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
{userState.user && userState.user.wechat_id !== ''
{isBound(userState.user?.wechat_id)
? t('修改绑定')
: status.wechat_login
? t('绑定')
@@ -220,8 +236,7 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
(userState.user && userState.user.github_id !== '') ||
!status.github_oauth
isBound(userState.user?.github_id) || !status.github_oauth
}
>
{status.github_oauth ? t('绑定') : t('未启用')}
@@ -264,8 +279,7 @@ const AccountManagement = ({
)
}
disabled={
(userState.user && userState.user.oidc_id !== '') ||
!status.oidc_enabled
isBound(userState.user?.oidc_id) || !status.oidc_enabled
}
>
{status.oidc_enabled ? t('绑定') : t('未启用')}
@@ -298,26 +312,56 @@ const AccountManagement = ({
</div>
<div className='flex-shrink-0'>
{status.telegram_oauth ? (
userState.user.telegram_id !== '' ? (
<Button disabled={true} size='small'>
isBound(userState.user?.telegram_id) ? (
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('已绑定')}
</Button>
) : (
<div className='scale-75'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
<Button
type='primary'
theme='outline'
size='small'
onClick={() => setShowTelegramBindModal(true)}
>
{t('绑定')}
</Button>
)
) : (
<Button disabled={true} size='small'>
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('未启用')}
</Button>
)}
</div>
</div>
</Card>
<Modal
title={t('绑定 Telegram')}
visible={showTelegramBindModal}
onCancel={() => setShowTelegramBindModal(false)}
footer={null}
>
<div className='my-3 text-sm text-gray-600'>
{t('点击下方按钮通过 Telegram 完成绑定')}
</div>
<div className='flex justify-center'>
<div className='scale-90'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
</div>
</Modal>
{/* LinuxDO绑定 */}
<Card className='!rounded-xl'>
@@ -350,8 +394,7 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
(userState.user && userState.user.linux_do_id !== '') ||
!status.linuxdo_oauth
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
}
>
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
@@ -443,6 +486,71 @@ const AccountManagement = ({
</div>
</Card>
{/* Passkey 设置 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className='mb-1'>
{t('Passkey 登录')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{passkeyEnabled
? t('已启用 Passkey无需密码即可登录')
: t('使用 Passkey 实现免密且更安全的登录体验')}
</Typography.Text>
<div className='mt-2 text-xs text-gray-500 space-y-1'>
<div>
{t('最后使用时间')}{lastUsedLabel}
</div>
{/*{passkeyEnabled && (*/}
{/* <div>*/}
{/* {t('备份支持')}*/}
{/* {passkeyStatus?.backup_eligible*/}
{/* ? t('支持备份')*/}
{/* : t('不支持')}*/}
{/* {t('备份状态')}*/}
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
{/* </div>*/}
{/*)}*/}
{!passkeySupported && (
<div className='text-amber-600'>
{t('当前设备不支持 Passkey')}
</div>
)}
</div>
</div>
</div>
<Button
type={passkeyEnabled ? 'danger' : 'primary'}
theme={passkeyEnabled ? 'solid' : 'solid'}
onClick={
passkeyEnabled
? () => {
Modal.confirm({
title: t('确认解绑 Passkey'),
content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
okText: t('确认解绑'),
cancelText: t('取消'),
okType: 'danger',
onOk: onPasskeyDelete,
});
}
: onPasskeyRegister
}
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
icon={<IconKey />}
disabled={!passkeySupported && !passkeyEnabled}
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
>
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
</Button>
</div>
</Card>
{/* 两步验证设置 */}
<TwoFASetting t={t} />

View File

@@ -400,6 +400,7 @@ const NotificationSettings = ({
<Radio value='email'>{t('邮件通知')}</Radio>
<Radio value='webhook'>{t('Webhook通知')}</Radio>
<Radio value='bark'>{t('Bark通知')}</Radio>
<Radio value='gotify'>{t('Gotify通知')}</Radio>
</Form.RadioGroup>
<Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Bark 官方文档
Bark {t('官方文档')}
</a>
</div>
</div>
</div>
</>
)}
{/* Gotify推送设置 */}
{notificationSettings.warningType === 'gotify' && (
<>
<Form.Input
field='gotifyUrl'
label={t('Gotify服务器地址')}
placeholder={t(
'请输入Gotify服务器地址例如: https://gotify.example.com',
)}
onChange={(val) => handleFormChange('gotifyUrl', val)}
prefix={<IconLink />}
extraText={t(
'支持HTTP和HTTPS填写Gotify服务器的完整URL地址',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify服务器地址'),
},
{
pattern: /^https?:\/\/.+/,
message: t('Gotify服务器地址必须以http://或https://开头'),
},
]}
/>
<Form.Input
field='gotifyToken'
label={t('Gotify应用令牌')}
placeholder={t('请输入Gotify应用令牌')}
onChange={(val) => handleFormChange('gotifyToken', val)}
prefix={<IconKey />}
extraText={t(
'在Gotify服务器创建应用后获得的令牌用于发送通知',
)}
showClear
rules={[
{
required:
notificationSettings.warningType === 'gotify',
message: t('请输入Gotify应用令牌'),
},
]}
/>
<Form.AutoComplete
field='gotifyPriority'
label={t('消息优先级')}
placeholder={t('请选择消息优先级')}
data={[
{ value: 0, label: t('0 - 最低') },
{ value: 2, label: t('2 - 低') },
{ value: 5, label: t('5 - 正常(默认)') },
{ value: 8, label: t('8 - 高') },
{ value: 10, label: t('10 - 最高') },
]}
onChange={(val) =>
handleFormChange('gotifyPriority', val)
}
prefix={<IconBell />}
extraText={t('消息优先级范围0-10默认为5')}
style={{ width: '100%', maxWidth: '300px' }}
/>
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
<div className='text-sm text-gray-700 mb-3'>
<strong>{t('配置说明')}</strong>
</div>
<div className='text-xs text-gray-500 space-y-2'>
<div>
1. {t('在Gotify服务器的应用管理中创建新应用')}
</div>
<div>
2.{' '}
{t(
'复制应用的令牌Token并填写到上方的应用令牌字段',
)}
</div>
<div>
3. {t('填写Gotify服务器的完整URL地址')}
</div>
<div className='mt-3 pt-3 border-t border-gray-200'>
<span className='text-gray-400'>
{t('更多信息请参考')}
</span>{' '}
<a
href='https://gotify.net/'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 font-medium'
>
Gotify {t('官方文档')}
</a>
</div>
</div>

View File

@@ -56,8 +56,10 @@ import {
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../../services/secureVerification';
import {
IconSave,
IconClose,
@@ -66,6 +68,8 @@ import {
IconCode,
IconGlobe,
IconBolt,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -85,6 +89,27 @@ const REGION_EXAMPLE = {
'claude-3-5-sonnet-20240620': 'europe-west1',
};
// 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([
1,
4,
14,
34,
17,
26,
24,
47,
25,
20,
23,
31,
35,
40,
42,
48,
43,
]);
function type2secretPrompt(type) {
// inputs.type === 15 ? '按照如下格式输入APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
switch (type) {
@@ -144,6 +169,12 @@ const EditChannelModal = (props) => {
settings: '',
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type
vertex_key_type: 'json',
// 企业账户设置
is_enterprise_account: false,
// 字段透传控制默认值
allow_service_tier: false,
disable_store: false, // false = 允许透传(默认开启)
allow_safety_identifier: false,
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -169,13 +200,11 @@ const EditChannelModal = (props) => {
const [channelSearchValue, setChannelSearchValue] = useState('');
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
const [keyMode, setKeyMode] = useState('append'); // 密钥模式replace覆盖或 append追加
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
// 2FA验证查看密钥相关状态
const [twoFAState, setTwoFAState] = useState({
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: '',
});
@@ -184,18 +213,57 @@ const EditChannelModal = (props) => {
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
// 表单块导航相关状态
const formSectionRefs = useRef({
basicInfo: null,
apiConfig: null,
modelConfig: null,
advancedSettings: null,
channelExtraSettings: null,
});
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings'];
const formContainerRef = useRef(null);
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState((prev) => ({ ...prev, ...updates }));
};
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
withVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后显示密钥
console.log('Verification success, result:', result);
if (result && result.success && result.data?.key) {
showSuccess(t('密钥获取成功'));
setKeyDisplayState({
showModal: true,
keyData: result.data.key,
});
} else if (result && result.key) {
// 直接返回了 key没有包装在 data 中)
showSuccess(t('密钥获取成功'));
setKeyDisplayState({
showModal: true,
keyData: result.key,
});
}
},
});
// 重置2FA状态
const resetTwoFAState = () => {
setTwoFAState({
// 重置密钥显示状态
const resetKeyDisplayState = () => {
setKeyDisplayState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: '',
});
};
@@ -207,6 +275,37 @@ const EditChannelModal = (props) => {
setVerifyLoading(false);
};
// 表单导航功能
const scrollToSection = (sectionKey) => {
const sectionElement = formSectionRefs.current[sectionKey];
if (sectionElement) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
};
const navigateToSection = (direction) => {
const availableSections = formSections.filter(section => {
if (section === 'apiConfig') {
return showApiConfigCard;
}
return true;
});
let newIndex;
if (direction === 'up') {
newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1;
} else {
newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
}
setCurrentSectionIndex(newIndex);
scrollToSection(availableSections[newIndex]);
};
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -215,7 +314,7 @@ const EditChannelModal = (props) => {
pass_through_body_enabled: false,
system_prompt: '',
});
const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示)
const showApiConfigCard = true; // 控制是否显示 API 配置卡片
const getInitValues = () => ({ ...originInputs });
// 处理渠道额外设置的更新
@@ -322,6 +421,10 @@ const EditChannelModal = (props) => {
case 36:
localModels = ['suno_music', 'suno_lyrics'];
break;
case 45:
localModels = getChannelModels(value);
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
break;
default:
localModels = getChannelModels(value);
break;
@@ -413,15 +516,37 @@ const EditChannelModal = (props) => {
parsedSettings.azure_responses_version || '';
// 读取 Vertex 密钥格式
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
// 读取企业账户设置
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
// 读取字段透传控制设置
data.allow_service_tier = parsedSettings.allow_service_tier || false;
data.disable_store = parsedSettings.disable_store || false;
data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false;
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
} else {
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
data.vertex_key_type = 'json';
data.is_enterprise_account = false;
data.allow_service_tier = false;
data.disable_store = false;
data.allow_safety_identifier = false;
}
if (
data.type === 45 &&
(!data.base_url ||
(typeof data.base_url === 'string' && data.base_url.trim() === ''))
) {
data.base_url = 'https://ark.cn-beijing.volces.com';
}
setInputs(data);
@@ -433,6 +558,8 @@ const EditChannelModal = (props) => {
} else {
setAutoBan(true);
}
// 同步企业账户状态
setIsEnterpriseAccount(data.is_enterprise_account || false);
setBasicModels(getChannelModels(data.type));
// 同步更新channelSettings状态显示
setChannelSettings({
@@ -561,42 +688,33 @@ const EditChannelModal = (props) => {
}
};
// 使用TwoFactorAuthModal的验证函数
const handleVerify2FA = async () => {
if (!verifyCode) {
showError(t('请输入验证码或备用码'));
return;
}
setVerifyLoading(true);
// 查看渠道密钥(透明验证)
const handleShow2FAModal = async () => {
try {
const res = await API.post(`/api/channel/${channelId}/key`, {
code: verifyCode,
});
if (res.data.success) {
// 验证成功,显示密钥
updateTwoFAState({
// 使用 withVerification 包装,会自动处理需要验证的情况
const result = await withVerification(
createApiCalls.viewChannelKey(channelId),
{
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 优先使用 Passkey
}
);
// 如果直接返回了结果(已验证),显示密钥
if (result && result.success && result.data?.key) {
showSuccess(t('密钥获取成功'));
setKeyDisplayState({
showModal: true,
showKey: true,
keyData: res.data.data.key,
keyData: result.data.key,
});
reset2FAVerifyState();
showSuccess(t('验证成功'));
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('获取密钥失败'));
} finally {
setVerifyLoading(false);
console.error('Failed to view channel key:', error);
showError(error.message || t('获取密钥失败'));
}
};
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
const handleShow2FAModal = () => {
setShow2FAVerifyModal(true);
};
useEffect(() => {
const modelMap = new Map();
@@ -672,6 +790,8 @@ const EditChannelModal = (props) => {
fetchModelGroups();
// 重置手动输入模式状态
setUseManualInput(false);
// 重置导航状态
setCurrentSectionIndex(0);
} else {
// 统一的模态框关闭重置逻辑
resetModalState();
@@ -692,16 +812,16 @@ const EditChannelModal = (props) => {
});
// 重置密钥模式状态
setKeyMode('append');
// 重置企业账户状态
setIsEnterpriseAccount(false);
// 清空表单中的key_mode字段
if (formApiRef.current) {
formApiRef.current.setValue('key_mode', undefined);
}
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
setInputs(getInitValues());
// 重置2FA状态
resetTwoFAState();
// 重置2FA验证状态
reset2FAVerifyState();
// 重置密钥显示状态
resetKeyDisplayState();
};
const handleVertexUploadChange = ({ fileList }) => {
@@ -802,7 +922,9 @@ const EditChannelModal = (props) => {
delete localInputs.key;
}
} else {
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
localInputs.key = batch
? JSON.stringify(keys)
: JSON.stringify(keys[0]);
}
}
}
@@ -822,6 +944,10 @@ const EditChannelModal = (props) => {
showInfo(t('请至少选择一个模型!'));
return;
}
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
showInfo(t('请输入API地址'));
return;
}
if (
localInputs.model_mapping &&
localInputs.model_mapping !== '' &&
@@ -851,6 +977,33 @@ const EditChannelModal = (props) => {
};
localInputs.setting = JSON.stringify(channelExtraSettings);
// 处理 settings 字段(包括企业账户设置和字段透传控制)
let settings = {};
if (localInputs.settings) {
try {
settings = JSON.parse(localInputs.settings);
} catch (error) {
console.error('解析settings失败:', error);
}
}
// type === 20: 设置企业账户标识无论是true还是false都要传到后端
if (localInputs.type === 20) {
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
}
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
if (localInputs.type === 1 || localInputs.type === 14) {
settings.allow_service_tier = localInputs.allow_service_tier === true;
// 仅 OpenAI 渠道需要 store 和 safety_identifier
if (localInputs.type === 1) {
settings.disable_store = localInputs.disable_store === true;
settings.allow_safety_identifier = localInputs.allow_safety_identifier === true;
}
}
localInputs.settings = JSON.stringify(settings);
// 清理不需要发送到后端的字段
delete localInputs.force_format;
delete localInputs.thinking_to_content;
@@ -858,8 +1011,13 @@ const EditChannelModal = (props) => {
delete localInputs.pass_through_body_enabled;
delete localInputs.system_prompt;
delete localInputs.system_prompt_override;
delete localInputs.is_enterprise_account;
// 顶层的 vertex_key_type 不应发送给后端
delete localInputs.vertex_key_type;
// 清理字段透传控制的临时字段
delete localInputs.allow_service_tier;
delete localInputs.disable_store;
delete localInputs.allow_safety_identifier;
let res;
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
@@ -899,6 +1057,56 @@ const EditChannelModal = (props) => {
}
};
// 密钥去重函数
const deduplicateKeys = () => {
const currentKey = formApiRef.current?.getValue('key') || inputs.key || '';
if (!currentKey.trim()) {
showInfo(t('请先输入密钥'));
return;
}
// 按行分割密钥
const keyLines = currentKey.split('\n');
const beforeCount = keyLines.length;
// 使用哈希表去重,保持原有顺序
const keySet = new Set();
const deduplicatedKeys = [];
keyLines.forEach((line) => {
const trimmedLine = line.trim();
if (trimmedLine && !keySet.has(trimmedLine)) {
keySet.add(trimmedLine);
deduplicatedKeys.push(trimmedLine);
}
});
const afterCount = deduplicatedKeys.length;
const deduplicatedKeyText = deduplicatedKeys.join('\n');
// 更新表单和状态
if (formApiRef.current) {
formApiRef.current.setValue('key', deduplicatedKeyText);
}
handleInputChange('key', deduplicatedKeyText);
// 显示去重结果
const message = t(
'去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥',
{
before: beforeCount,
after: afterCount,
},
);
if (beforeCount === afterCount) {
showInfo(t('未发现重复密钥'));
} else {
showSuccess(message);
}
};
const addCustomModels = () => {
if (customModel.trim() === '') return;
const modelArray = customModel.split(',').map((model) => model.trim());
@@ -994,24 +1202,41 @@ const EditChannelModal = (props) => {
</Checkbox>
)}
{batch && (
<Checkbox
disabled={isEdit}
checked={multiToSingle}
onChange={() => {
setMultiToSingle((prev) => !prev);
setInputs((prev) => {
const newInputs = { ...prev };
if (!multiToSingle) {
newInputs.multi_key_mode = multiKeyMode;
} else {
delete newInputs.multi_key_mode;
}
return newInputs;
});
}}
>
{t('密钥聚合模式')}
</Checkbox>
<>
<Checkbox
disabled={isEdit}
checked={multiToSingle}
onChange={() => {
setMultiToSingle((prev) => {
const nextValue = !prev;
setInputs((prevInputs) => {
const newInputs = { ...prevInputs };
if (nextValue) {
newInputs.multi_key_mode = multiKeyMode;
} else {
delete newInputs.multi_key_mode;
}
return newInputs;
});
return nextValue;
});
}}
>
{t('密钥聚合模式')}
</Checkbox>
{inputs.type !== 41 && (
<Button
size='small'
type='tertiary'
theme='outline'
onClick={deduplicateKeys}
style={{ textDecoration: 'underline' }}
>
{t('密钥去重')}
</Button>
)}
</>
)}
</Space>
) : null;
@@ -1108,7 +1333,41 @@ const EditChannelModal = (props) => {
visible={props.visible}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<div className='flex justify-between items-center bg-white'>
<div className='flex gap-2'>
<Button
size='small'
type='tertiary'
icon={<IconChevronUp />}
onClick={() => navigateToSection('up')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('上一个表单块')}
/>
<Button
size='small'
type='tertiary'
icon={<IconChevronDown />}
onClick={() => navigateToSection('down')}
style={{
borderRadius: '50%',
width: '32px',
height: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={t('下一个表单块')}
/>
</div>
<Space>
<Button
theme='solid'
@@ -1139,10 +1398,14 @@ const EditChannelModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div className='p-2'>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<div
className='p-2'
ref={formContainerRef}
>
<div ref={el => formSectionRefs.current.basicInfo = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='blue'
@@ -1175,6 +1438,21 @@ const EditChannelModal = (props) => {
onChange={(value) => handleInputChange('type', value)}
/>
{inputs.type === 20 && (
<Form.Switch
field='is_enterprise_account'
label={t('是否为企业账户')}
checkedText={t('是')}
uncheckedText={t('否')}
onChange={(value) => {
setIsEnterpriseAccount(value);
handleInputChange('is_enterprise_account', value);
}}
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
initValue={inputs.is_enterprise_account}
/>
)}
<Form.Input
field='name'
label={t('名称')}
@@ -1198,7 +1476,10 @@ const EditChannelModal = (props) => {
value={inputs.vertex_key_type || 'json'}
onChange={(value) => {
// 更新设置中的 vertex_key_type
handleChannelOtherSettingsChange('vertex_key_type', value);
handleChannelOtherSettingsChange(
'vertex_key_type',
value,
);
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
if (value === 'api_key') {
setBatch(false);
@@ -1218,7 +1499,8 @@ const EditChannelModal = (props) => {
/>
)}
{batch ? (
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<Form.Upload
field='vertex_files'
label={t('密钥文件 (.json)')}
@@ -1254,7 +1536,7 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={
<div className='flex items-center gap-2'>
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
@@ -1282,7 +1564,8 @@ const EditChannelModal = (props) => {
)
) : (
<>
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
@@ -1596,13 +1879,15 @@ const EditChannelModal = (props) => {
}
/>
)}
</Card>
</Card>
</div>
{/* API Configuration Card */}
{showApiConfigCard && (
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.apiConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
@@ -1789,13 +2074,39 @@ const EditChannelModal = (props) => {
/>
</div>
)}
</Card>
{inputs.type === 45 && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
handleInputChange('base_url', value)
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com'
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com'
}
]}
defaultValue='https://ark.cn-beijing.volces.com'
/>
</div>
)}
</Card>
</div>
)}
{/* Model Configuration Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.modelConfig = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='purple'
@@ -1872,13 +2183,15 @@ const EditChannelModal = (props) => {
>
{t('填入所有模型')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => fetchUpstreamModelList('models')}
>
{t('获取模型列表')}
</Button>
{MODEL_FETCHABLE_TYPES.has(inputs.type) && (
<Button
size='small'
type='tertiary'
onClick={() => fetchUpstreamModelList('models')}
>
{t('获取模型列表')}
</Button>
)}
<Button
size='small'
type='warning'
@@ -1988,12 +2301,14 @@ const EditChannelModal = (props) => {
formApi={formApiRef.current}
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
/>
</Card>
</Card>
</div>
{/* Advanced Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.advancedSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='orange'
@@ -2206,12 +2521,84 @@ const EditChannelModal = (props) => {
'键为原状态码,值为要复写的状态码,仅影响本地判断',
)}
/>
</Card>
{/* 字段透传控制 - OpenAI 渠道 */}
{inputs.type === 1 && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
<Form.Switch
field='disable_store'
label={t('禁用 store 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('disable_store', value)
}
extraText={t(
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
)}
/>
<Form.Switch
field='allow_safety_identifier'
label={t('允许 safety_identifier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_safety_identifier', value)
}
extraText={t(
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
)}
/>
</>
)}
{/* 字段透传控制 - Claude 渠道 */}
{(inputs.type === 14) && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
</div>
<Form.Switch
field='allow_service_tier'
label={t('允许 service_tier 透传')}
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
)}
/>
</>
)}
</Card>
</div>
{/* Channel Extra Settings Card */}
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='violet'
@@ -2309,7 +2696,8 @@ const EditChannelModal = (props) => {
'如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面',
)}
/>
</Card>
</Card>
</div>
</div>
</Spin>
)}
@@ -2320,17 +2708,17 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</SideSheet>
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
<TwoFactorAuthModal
visible={show2FAVerifyModal}
code={verifyCode}
loading={verifyLoading}
onCodeChange={setVerifyCode}
onVerify={handleVerify2FA}
onCancel={reset2FAVerifyState}
title={t('查看渠道密钥')}
description={t('为了保护账户安全,请验证您的两步验证码。')}
placeholder={t('请输入验证码或备用码')}
{/* 使用通用安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 使用ChannelKeyDisplay组件显示密钥 */}
@@ -2353,10 +2741,10 @@ const EditChannelModal = (props) => {
{t('渠道密钥信息')}
</div>
}
visible={twoFAState.showModal && twoFAState.showKey}
onCancel={resetTwoFAState}
visible={keyDisplayState.showModal}
onCancel={resetKeyDisplayState}
footer={
<Button type='primary' onClick={resetTwoFAState}>
<Button type='primary' onClick={resetKeyDisplayState}>
{t('完成')}
</Button>
}
@@ -2364,7 +2752,7 @@ const EditChannelModal = (props) => {
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={twoFAState.keyData}
keyData={keyDisplayState.keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}

View File

@@ -118,6 +118,9 @@ const EditTagModal = (props) => {
case 36:
localModels = ['suno_music', 'suno_lyrics'];
break;
case 53:
localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
break;
default:
localModels = getChannelModels(value);
break;

View File

@@ -25,6 +25,7 @@ import {
Table,
Tag,
Typography,
Select,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
@@ -45,6 +46,8 @@ const ModelTestModal = ({
testChannel,
modelTablePage,
setModelTablePage,
selectedEndpointType,
setSelectedEndpointType,
allSelectingRef,
isMobile,
t,
@@ -59,6 +62,17 @@ const ModelTestModal = ({
)
: [];
const endpointTypeOptions = [
{ value: '', label: t('自动检测') },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{ value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
{ value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
];
const handleCopySelected = () => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
@@ -152,7 +166,7 @@ const ModelTestModal = ({
return (
<Button
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
loading={isTesting}
size='small'
>
@@ -228,6 +242,18 @@ const ModelTestModal = ({
>
{hasChannel && (
<div className='model-test-scroll'>
{/* 端点类型选择器 */}
<div className='flex items-center gap-2 w-full mb-2'>
<Typography.Text strong>{t('端点类型')}:</Typography.Text>
<Select
value={selectedEndpointType}
onChange={setSelectedEndpointType}
optionList={endpointTypeOptions}
className='!w-full'
placeholder={t('选择端点类型')}
/>
</div>
{/* 搜索与操作按钮 */}
<div className='flex items-center justify-end gap-2 w-full mb-2'>
<Input

View File

@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
import React, { useState, useEffect } from 'react';
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
const { Text } = Typography;
const ContentModal = ({
isModalOpen,
@@ -26,17 +29,120 @@ const ContentModal = ({
modalContent,
isVideo,
}) => {
const [videoError, setVideoError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isModalOpen && isVideo) {
setVideoError(false);
setIsLoading(true);
}
}, [isModalOpen, isVideo]);
const handleVideoError = () => {
setVideoError(true);
setIsLoading(false);
};
const handleVideoLoaded = () => {
setIsLoading(false);
};
const handleCopyUrl = () => {
navigator.clipboard.writeText(modalContent);
};
const handleOpenInNewTab = () => {
window.open(modalContent, '_blank');
};
const renderVideoContent = () => {
if (videoError) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
视频无法在当前浏览器中播放这可能是由于
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
视频服务商的跨域限制
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
需要特定的请求头或认证
</Text>
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
防盗链保护机制
</Text>
<div style={{ marginTop: '20px' }}>
<Button
icon={<IconExternalOpen />}
onClick={handleOpenInNewTab}
style={{ marginRight: '8px' }}
>
在新标签页中打开
</Button>
<Button
icon={<IconCopy />}
onClick={handleCopyUrl}
>
复制链接
</Button>
</div>
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
<Text
type="tertiary"
style={{ fontSize: '10px', wordBreak: 'break-all' }}
>
{modalContent}
</Text>
</div>
</div>
);
}
return (
<div style={{ position: 'relative' }}>
{isLoading && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10
}}>
<Spin size="large" />
</div>
)}
<video
src={modalContent}
controls
style={{ width: '100%' }}
autoPlay
crossOrigin="anonymous"
onError={handleVideoError}
onLoadedData={handleVideoLoaded}
onLoadStart={() => setIsLoading(true)}
/>
</div>
);
};
return (
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }}
bodyStyle={{
height: isVideo ? '450px' : '400px',
overflow: 'auto',
padding: isVideo && videoError ? '0' : '24px'
}}
width={800}
>
{isVideo ? (
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
renderVideoContent()
) : (
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
)}

View File

@@ -26,7 +26,9 @@ import {
Progress,
Popover,
Typography,
Dropdown,
} from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
/**
@@ -204,6 +206,8 @@ const renderOperations = (
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
},
) => {
@@ -211,6 +215,28 @@ const renderOperations = (
return <></>;
}
const moreMenu = [
{
node: 'item',
name: t('重置 Passkey'),
onClick: () => showResetPasskeyModal(record),
},
{
node: 'item',
name: t('重置 2FA'),
onClick: () => showResetTwoFAModal(record),
},
{
node: 'divider',
},
{
node: 'item',
name: t('注销'),
type: 'danger',
onClick: () => showDeleteModal(record),
},
];
return (
<Space>
{record.status === 1 ? (
@@ -253,13 +279,17 @@ const renderOperations = (
>
{t('降级')}
</Button>
<Button
type='danger'
size='small'
onClick={() => showDeleteModal(record)}
<Dropdown
menu={moreMenu}
trigger='click'
position='bottomRight'
>
{t('注销')}
</Button>
<Button
type='tertiary'
size='small'
icon={<IconMore />}
/>
</Dropdown>
</Space>
);
};
@@ -275,6 +305,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
}) => {
return [
{
@@ -329,6 +361,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
}),
},

View File

@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
import DemoteUserModal from './modals/DemoteUserModal';
import EnableDisableUserModal from './modals/EnableDisableUserModal';
import DeleteUserModal from './modals/DeleteUserModal';
import ResetPasskeyModal from './modals/ResetPasskeyModal';
import ResetTwoFAModal from './modals/ResetTwoFAModal';
const UsersTable = (usersData) => {
const {
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
setShowEditUser,
manageUser,
refresh,
resetUserPasskey,
resetUserTwoFA,
t,
} = usersData;
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [modalUser, setModalUser] = useState(null);
const [enableDisableAction, setEnableDisableAction] = useState('');
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
// Modal handlers
const showPromoteUserModal = (user) => {
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
setShowDeleteModal(true);
};
const showResetPasskeyUserModal = (user) => {
setModalUser(user);
setShowResetPasskeyModal(true);
};
const showResetTwoFAUserModal = (user) => {
setModalUser(user);
setShowResetTwoFAModal(true);
};
// Modal confirm handlers
const handlePromoteConfirm = () => {
manageUser(modalUser.id, 'promote', modalUser);
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
setShowEnableDisableModal(false);
};
const handleResetPasskeyConfirm = async () => {
await resetUserPasskey(modalUser);
setShowResetPasskeyModal(false);
};
const handleResetTwoFAConfirm = async () => {
await resetUserTwoFA(modalUser);
setShowResetTwoFAModal(false);
};
// Get all columns
const columns = useMemo(() => {
return getUsersColumns({
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
showDemoteModal: showDemoteUserModal,
showEnableDisableModal: showEnableDisableUserModal,
showDeleteModal: showDeleteUserModal,
showResetPasskeyModal: showResetPasskeyUserModal,
showResetTwoFAModal: showResetTwoFAUserModal,
});
}, [t, setEditingUser, setShowEditUser]);
}, [
t,
setEditingUser,
setShowEditUser,
showPromoteUserModal,
showDemoteUserModal,
showEnableDisableUserModal,
showDeleteUserModal,
showResetPasskeyUserModal,
showResetTwoFAUserModal,
]);
// Handle compact mode by removing fixed positioning
const tableColumns = useMemo(() => {
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
manageUser={manageUser}
t={t}
/>
<ResetPasskeyModal
visible={showResetPasskeyModal}
onCancel={() => setShowResetPasskeyModal(false)}
onConfirm={handleResetPasskeyConfirm}
user={modalUser}
t={t}
/>
<ResetTwoFAModal
visible={showResetTwoFAModal}
onCancel={() => setShowResetTwoFAModal(false)}
onConfirm={handleResetTwoFAConfirm}
user={modalUser}
t={t}
/>
</>
);
};

View File

@@ -0,0 +1,39 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置 Passkey')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将解绑用户当前的 Passkey下次登录需要重新注册。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetPasskeyModal;

View File

@@ -0,0 +1,39 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置两步验证')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetTwoFAModal;

View File

@@ -159,6 +159,16 @@ export const CHANNEL_OPTIONS = [
color: 'purple',
label: 'Vidu',
},
{
value: 53,
color: 'blue',
label: 'SubModel',
},
{
value: 54,
color: 'blue',
label: '豆包视频',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;

View File

@@ -118,7 +118,6 @@ export const buildApiPayload = (
model: inputs.model,
group: inputs.group,
messages: processedMessages,
group: inputs.group,
stream: inputs.stream,
};
@@ -132,13 +131,15 @@ export const buildApiPayload = (
seed: 'seed',
};
Object.entries(parameterMappings).forEach(([key, param]) => {
if (
parameterEnabled[key] &&
inputs[param] !== undefined &&
inputs[param] !== null
) {
payload[param] = inputs[param];
const enabled = parameterEnabled[key];
const value = inputs[param];
const hasValue = value !== undefined && value !== null;
if (enabled && hasValue) {
payload[param] = value;
}
});

View File

@@ -27,3 +27,4 @@ export * from './data';
export * from './token';
export * from './boolean';
export * from './dashboard';
export * from './passkey';

137
web/src/helpers/passkey.js Normal file
View File

@@ -0,0 +1,137 @@
export function base64UrlToBuffer(base64url) {
if (!base64url) return new ArrayBuffer(0);
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const uintArray = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; i += 1) {
uintArray[i] = rawData.charCodeAt(i);
}
return buffer;
}
export function bufferToBase64Url(buffer) {
if (!buffer) return '';
const uintArray = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < uintArray.byteLength; i += 1) {
binary += String.fromCharCode(uintArray[i]);
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function prepareCredentialCreationOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
user: {
...options.user,
id: base64UrlToBuffer(options.user?.id),
},
};
if (Array.isArray(options.excludeCredentials)) {
publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
delete publicKey.attestationFormats;
}
return publicKey;
}
export function prepareCredentialRequestOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
};
if (Array.isArray(options.allowCredentials)) {
publicKey.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
return publicKey;
}
export function buildRegistrationResult(credential) {
if (!credential) return null;
const { response } = credential;
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports,
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
};
}
export function buildAssertionResult(assertion) {
if (!assertion) return null;
const { response } = assertion;
return {
id: assertion.id,
rawId: bufferToBase64Url(assertion.rawId),
type: assertion.type,
authenticatorAttachment: assertion.authenticatorAttachment,
response: {
authenticatorData: bufferToBase64Url(response.authenticatorData),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
},
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
};
}
export async function isPasskeySupported() {
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
return false;
}
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
try {
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
if (available) return true;
} catch (error) {
// ignore
}
}
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (error) {
return false;
}
}
return true;
}

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