From 0a858460988cf4bc6c2558e00c9f41a680d455a5 Mon Sep 17 00:00:00 2001
From: wzxjohn
Date: Fri, 16 May 2025 16:44:47 +0800
Subject: [PATCH 01/89] feat: support UMAMI analytics
---
main.go | 18 ++++++++++++++++++
web/index.html | 1 +
2 files changed, 19 insertions(+)
diff --git a/main.go b/main.go
index 95c6820d..e9f55b09 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"embed"
"fmt"
"log"
@@ -15,6 +16,7 @@ import (
"one-api/setting/operation_setting"
"os"
"strconv"
+ "strings"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
@@ -161,6 +163,22 @@ 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("")
+ }
+ analyticsInject := analyticsInjectBuilder.String()
+ indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject))
+
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
diff --git a/web/index.html b/web/index.html
index 1e75f3d7..c6ce7b84 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,6 +10,7 @@
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
/>
New API
+
From 85723e1fe33c9c28d88acf34ca505de05173d89d Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Sun, 10 Aug 2025 16:34:53 +0800
Subject: [PATCH 02/89] feat: if video cannot play open in a new tab
---
.../table/task-logs/modals/ContentModal.jsx | 114 +++++++++++++++++-
1 file changed, 110 insertions(+), 4 deletions(-)
diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx
index a6f16c98..fd17c206 100644
--- a/web/src/components/table/task-logs/modals/ContentModal.jsx
+++ b/web/src/components/table/task-logs/modals/ContentModal.jsx
@@ -17,8 +17,11 @@ along with this program. If not, see .
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 (
+
+
+ 视频无法在当前浏览器中播放,这可能是由于:
+
+
+ • 视频服务商的跨域限制
+
+
+ • 需要特定的请求头或认证
+
+
+ • 防盗链保护机制
+
+
+
+ }
+ onClick={handleOpenInNewTab}
+ style={{ marginRight: '8px' }}
+ >
+ 在新标签页中打开
+
+ }
+ onClick={handleCopyUrl}
+ >
+ 复制链接
+
+
+
+
+
+ {modalContent}
+
+
+
+ );
+ }
+
+ return (
+
+ {isLoading && (
+
+
+
+ )}
+
+ );
+ };
+
return (
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 ? (
-
+ renderVideoContent()
) : (
{modalContent}
)}
From 2875dbba1026fb228e66f4de067c3204cbde64fa Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Sat, 30 Aug 2025 23:28:09 +0800
Subject: [PATCH 03/89] =?UTF-8?q?feat:=20=E5=9B=BE=E5=83=8F=E5=80=8D?=
=?UTF-8?q?=E7=8E=87=EF=BC=8C=E9=9F=B3=E9=A2=91=E5=80=8D=E7=8E=87=E5=92=8C?=
=?UTF-8?q?=E9=9F=B3=E9=A2=91=E8=A1=A5=E5=85=A8=E5=80=8D=E7=8E=87=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/option.go | 27 ++++
model/option.go | 9 ++
relay/helper/price.go | 6 +
setting/ratio_setting/model_ratio.go | 133 +++++++++++++++---
types/price_data.go | 4 +-
web/src/components/settings/RatioSetting.jsx | 8 +-
web/src/i18n/locales/en.json | 12 +-
.../Setting/Ratio/ModelRatioSettings.jsx | 69 +++++++++
8 files changed, 244 insertions(+), 24 deletions(-)
diff --git a/controller/option.go b/controller/option.go
index decdb0d4..fb54d20a 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -112,6 +112,33 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "ImageRatio":
+ err = ratio_setting.UpdateImageRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "图片倍率设置失败: " + err.Error(),
+ })
+ return
+ }
+ case "AudioRatio":
+ err = ratio_setting.UpdateAudioRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "音频倍率设置失败: " + err.Error(),
+ })
+ return
+ }
+ case "AudioCompletionRatio":
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "音频补全倍率设置失败: " + err.Error(),
+ })
+ return
+ }
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value)
if err != nil {
diff --git a/model/option.go b/model/option.go
index 2121710c..e589b46e 100644
--- a/model/option.go
+++ b/model/option.go
@@ -111,6 +111,9 @@ func InitOptionMap() {
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
+ common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
+ common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
+ common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -396,6 +399,12 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateModelPriceByJSONString(value)
case "CacheRatio":
err = ratio_setting.UpdateCacheRatioByJSONString(value)
+ case "ImageRatio":
+ err = ratio_setting.UpdateImageRatioByJSONString(value)
+ case "AudioRatio":
+ err = ratio_setting.UpdateAudioRatioByJSONString(value)
+ case "AudioCompletionRatio":
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":
diff --git a/relay/helper/price.go b/relay/helper/price.go
index fdc5b66d..c23c068b 100644
--- a/relay/helper/price.go
+++ b/relay/helper/price.go
@@ -52,6 +52,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
+ var audioRatio float64
+ var audioCompletionRatio float64
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
if meta.MaxTokens != 0 {
@@ -73,6 +75,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
+ audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
+ audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
@@ -90,6 +94,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
+ AudioRatio: audioRatio,
+ AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,
ShouldPreConsumedQuota: preConsumedQuota,
}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f06cd71e..99952daf 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -278,6 +278,18 @@ var defaultModelPrice = map[string]float64{
"mj_upload": 0.05,
}
+var defaultAudioRatio = map[string]float64{
+ "gpt-4o-audio-preview": 16,
+ "gpt-4o-mini-audio-preview": 66.67,
+ "gpt-4o-realtime-preview": 8,
+ "gpt-4o-mini-realtime-preview": 16.67,
+}
+
+var defaultAudioCompletionRatio = map[string]float64{
+ "gpt-4o-realtime": 2,
+ "gpt-4o-mini-realtime": 2,
+}
+
var (
modelPriceMap map[string]float64 = nil
modelPriceMapMutex = sync.RWMutex{}
@@ -326,6 +338,15 @@ func InitRatioSettings() {
imageRatioMap = defaultImageRatio
imageRatioMapMutex.Unlock()
+ // initialize audioRatioMap
+ audioRatioMapMutex.Lock()
+ audioRatioMap = defaultAudioRatio
+ audioRatioMapMutex.Unlock()
+
+ // initialize audioCompletionRatioMap
+ audioCompletionRatioMapMutex.Lock()
+ audioCompletionRatioMap = defaultAudioCompletionRatio
+ audioCompletionRatioMapMutex.Unlock()
}
func GetModelPriceMap() map[string]float64 {
@@ -417,6 +438,18 @@ func GetDefaultModelRatioMap() map[string]float64 {
return defaultModelRatio
}
+func GetDefaultImageRatioMap() map[string]float64 {
+ return defaultImageRatio
+}
+
+func GetDefaultAudioRatioMap() map[string]float64 {
+ return defaultAudioRatio
+}
+
+func GetDefaultAudioCompletionRatioMap() map[string]float64 {
+ return defaultAudioCompletionRatio
+}
+
func GetCompletionRatioMap() map[string]float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
@@ -584,32 +617,20 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
func GetAudioRatio(name string) float64 {
- if strings.Contains(name, "-realtime") {
- if strings.HasSuffix(name, "gpt-4o-realtime-preview") {
- return 8
- } else if strings.Contains(name, "gpt-4o-mini-realtime-preview") {
- return 10 / 0.6
- } else {
- return 20
- }
- }
- if strings.Contains(name, "-audio") {
- if strings.HasPrefix(name, "gpt-4o-audio-preview") {
- return 40 / 2.5
- } else if strings.HasPrefix(name, "gpt-4o-mini-audio-preview") {
- return 10 / 0.15
- } else {
- return 40
- }
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ if ratio, ok := audioRatioMap[name]; ok {
+ return ratio
}
return 20
}
func GetAudioCompletionRatio(name string) float64 {
- if strings.HasPrefix(name, "gpt-4o-realtime") {
- return 2
- } else if strings.HasPrefix(name, "gpt-4o-mini-realtime") {
- return 2
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ if ratio, ok := audioCompletionRatioMap[name]; ok {
+
+ return ratio
}
return 2
}
@@ -630,6 +651,14 @@ var defaultImageRatio = map[string]float64{
}
var imageRatioMap map[string]float64
var imageRatioMapMutex sync.RWMutex
+var (
+ audioRatioMap map[string]float64 = nil
+ audioRatioMapMutex = sync.RWMutex{}
+)
+var (
+ audioCompletionRatioMap map[string]float64 = nil
+ audioCompletionRatioMapMutex = sync.RWMutex{}
+)
func ImageRatio2JSONString() string {
imageRatioMapMutex.RLock()
@@ -658,6 +687,68 @@ func GetImageRatio(name string) (float64, bool) {
return ratio, true
}
+func AudioRatio2JSONString() string {
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ jsonBytes, err := common.Marshal(audioRatioMap)
+ if err != nil {
+ common.SysError("error marshalling audio ratio: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateAudioRatioByJSONString(jsonStr string) error {
+ audioRatioMapMutex.Lock()
+ defer audioRatioMapMutex.Unlock()
+ audioRatioMap = make(map[string]float64)
+ err := common.Unmarshal([]byte(jsonStr), &audioRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
+}
+
+func GetAudioRatioCopy() map[string]float64 {
+ audioRatioMapMutex.RLock()
+ defer audioRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(audioRatioMap))
+ for k, v := range audioRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
+func AudioCompletionRatio2JSONString() string {
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ jsonBytes, err := common.Marshal(audioCompletionRatioMap)
+ if err != nil {
+ common.SysError("error marshalling audio completion ratio: " + err.Error())
+ }
+ return string(jsonBytes)
+}
+
+func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
+ audioCompletionRatioMapMutex.Lock()
+ defer audioCompletionRatioMapMutex.Unlock()
+ audioCompletionRatioMap = make(map[string]float64)
+ err := common.Unmarshal([]byte(jsonStr), &audioCompletionRatioMap)
+ if err == nil {
+ InvalidateExposedDataCache()
+ }
+ return err
+}
+
+func GetAudioCompletionRatioCopy() map[string]float64 {
+ audioCompletionRatioMapMutex.RLock()
+ defer audioCompletionRatioMapMutex.RUnlock()
+ copyMap := make(map[string]float64, len(audioCompletionRatioMap))
+ for k, v := range audioCompletionRatioMap {
+ copyMap[k] = v
+ }
+ return copyMap
+}
+
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
diff --git a/types/price_data.go b/types/price_data.go
index f6a92d7e..ec7fcdfe 100644
--- a/types/price_data.go
+++ b/types/price_data.go
@@ -15,6 +15,8 @@ type PriceData struct {
CacheRatio float64
CacheCreationRatio float64
ImageRatio float64
+ AudioRatio float64
+ AudioCompletionRatio float64
UsePrice bool
ShouldPreConsumedQuota int
GroupRatioInfo GroupRatioInfo
@@ -27,5 +29,5 @@ type PerCallPriceData struct {
}
func (p PriceData) ToSetting() string {
- return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
+ return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
}
diff --git a/web/src/components/settings/RatioSetting.jsx b/web/src/components/settings/RatioSetting.jsx
index 096722bb..f5d8ef99 100644
--- a/web/src/components/settings/RatioSetting.jsx
+++ b/web/src/components/settings/RatioSetting.jsx
@@ -39,6 +39,9 @@ const RatioSetting = () => {
CompletionRatio: '',
GroupRatio: '',
GroupGroupRatio: '',
+ ImageRatio: '',
+ AudioRatio: '',
+ AudioCompletionRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
@@ -61,7 +64,10 @@ const RatioSetting = () => {
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
- item.key === 'CacheRatio'
+ item.key === 'CacheRatio' ||
+ item.key === 'ImageRatio' ||
+ item.key === 'AudioRatio' ||
+ item.key === 'AudioCompletionRatio'
) {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 877fa44f..62c8ebb7 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2017,5 +2017,15 @@
"查看密钥": "View key",
"查看渠道密钥": "View channel key",
"渠道密钥信息": "Channel key information",
- "密钥获取成功": "Key acquisition successful"
+ "密钥获取成功": "Key acquisition successful",
+ "模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
+ "图片倍率": "Image ratio",
+ "音频倍率": "Audio ratio",
+ "音频补全倍率": "Audio completion ratio",
+ "图片输入相关的倍率设置,键为模型名称,值为倍率": "Image input related ratio settings, key is model name, value is ratio",
+ "音频输入相关的倍率设置,键为模型名称,值为倍率": "Audio input related ratio settings, key is model name, value is ratio",
+ "音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
+ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}"
}
diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
index 2462a35a..b4095126 100644
--- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
@@ -44,6 +44,9 @@ export default function ModelRatioSettings(props) {
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
+ ImageRatio: '',
+ AudioRatio: '',
+ AudioCompletionRatio: '',
ExposeRatioEnabled: false,
});
const refForm = useRef();
@@ -219,6 +222,72 @@ export default function ModelRatioSettings(props) {
/>
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, ImageRatio: value })
+ }
+ />
+
+
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, AudioRatio: value })
+ }
+ />
+
+
+
+
+ verifyJSON(value),
+ message: '不是合法的 JSON 字符串',
+ },
+ ]}
+ onChange={(value) =>
+ setInputs({ ...inputs, AudioCompletionRatio: value })
+ }
+ />
+
+
Date: Sat, 30 Aug 2025 23:53:46 +0800
Subject: [PATCH 04/89] feat: improve ratio update
---
setting/ratio_setting/model_ratio.go | 33 ++++++++++++++++------------
1 file changed, 19 insertions(+), 14 deletions(-)
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 99952daf..5b47c875 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -619,6 +619,7 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
func GetAudioRatio(name string) float64 {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
+ name = FormatMatchingModelName(name)
if ratio, ok := audioRatioMap[name]; ok {
return ratio
}
@@ -628,6 +629,7 @@ func GetAudioRatio(name string) float64 {
func GetAudioCompletionRatio(name string) float64 {
audioCompletionRatioMapMutex.RLock()
defer audioCompletionRatioMapMutex.RUnlock()
+ name = FormatMatchingModelName(name)
if ratio, ok := audioCompletionRatioMap[name]; ok {
return ratio
@@ -698,14 +700,16 @@ func AudioRatio2JSONString() string {
}
func UpdateAudioRatioByJSONString(jsonStr string) error {
- audioRatioMapMutex.Lock()
- defer audioRatioMapMutex.Unlock()
- audioRatioMap = make(map[string]float64)
- err := common.Unmarshal([]byte(jsonStr), &audioRatioMap)
- if err == nil {
- InvalidateExposedDataCache()
+
+ tmp := make(map[string]float64)
+ if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
+ return err
}
- return err
+ audioRatioMapMutex.Lock()
+ audioRatioMap = tmp
+ audioRatioMapMutex.Unlock()
+ InvalidateExposedDataCache()
+ return nil
}
func GetAudioRatioCopy() map[string]float64 {
@@ -729,14 +733,15 @@ func AudioCompletionRatio2JSONString() string {
}
func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
- audioCompletionRatioMapMutex.Lock()
- defer audioCompletionRatioMapMutex.Unlock()
- audioCompletionRatioMap = make(map[string]float64)
- err := common.Unmarshal([]byte(jsonStr), &audioCompletionRatioMap)
- if err == nil {
- InvalidateExposedDataCache()
+ tmp := make(map[string]float64)
+ if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
+ return err
}
- return err
+ audioCompletionRatioMapMutex.Lock()
+ audioCompletionRatioMap = tmp
+ audioCompletionRatioMapMutex.Unlock()
+ InvalidateExposedDataCache()
+ return nil
}
func GetAudioCompletionRatioCopy() map[string]float64 {
From 2d893d250621565843a8b0e19c7a19798187b92e Mon Sep 17 00:00:00 2001
From: undefinedcodezhong
<40236765+undefinedcodezhong@users.noreply.github.com>
Date: Wed, 10 Sep 2025 10:41:44 +0800
Subject: [PATCH 05/89] =?UTF-8?q?fix=EF=BC=9AAccount=20Management=20Status?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../settings/personal/cards/AccountManagement.jsx | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx
index 515a5c19..235b4f3f 100644
--- a/web/src/components/settings/personal/cards/AccountManagement.jsx
+++ b/web/src/components/settings/personal/cards/AccountManagement.jsx
@@ -165,9 +165,10 @@ const AccountManagement = ({
{t('微信')}
- {userState.user && userState.user.wechat_id !== ''
- ? t('已绑定')
- : t('未绑定')}
+ {renderAccountInfo(
+ userState.user?.wechat_id,
+ t('微信 ID'),
+ )}
@@ -179,7 +180,7 @@ const AccountManagement = ({
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
- {userState.user && userState.user.wechat_id !== ''
+ {userState.user && userState.user?.wechat_id
? t('修改绑定')
: status.wechat_login
? t('绑定')
@@ -298,7 +299,7 @@ const AccountManagement = ({
{status.telegram_oauth ? (
- userState.user.telegram_id !== '' ? (
+ userState.user?.telegram_id ? (
From f48e8d51802ee58badf834736fbba5b53a2c9ca9 Mon Sep 17 00:00:00 2001
From: heimoshuiyu
Date: Thu, 11 Sep 2025 10:34:51 +0800
Subject: [PATCH 06/89] feat: add thousand separators to token display in
dashboard
---
web/src/hooks/dashboard/useDashboardStats.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/hooks/dashboard/useDashboardStats.jsx b/web/src/hooks/dashboard/useDashboardStats.jsx
index aa9677a5..dbf3b67e 100644
--- a/web/src/hooks/dashboard/useDashboardStats.jsx
+++ b/web/src/hooks/dashboard/useDashboardStats.jsx
@@ -102,7 +102,7 @@ export const useDashboardStats = (
},
{
title: t('统计Tokens'),
- value: isNaN(consumeTokens) ? 0 : consumeTokens,
+ value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
icon: ,
avatarColor: 'pink',
trendData: trendData.tokens,
From c3f5478593d5260681cc88334120ac078a3a7767 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 13 Sep 2025 17:34:22 +0800
Subject: [PATCH 07/89] feat: implement SSRF protection settings and update
related references
---
common/ip.go | 22 +
common/ssrf_protection.go | 384 ++++++++++++++++++
service/{cf_worker.go => download.go} | 26 +-
service/user_notify.go | 12 +-
service/webhook.go | 13 +-
setting/system_setting/fetch_setting.go | 28 ++
types/error.go | 12 +-
web/src/components/settings/SystemSetting.jsx | 200 +++++++++
web/src/i18n/locales/en.json | 24 +-
web/src/i18n/locales/zh.json | 24 +-
10 files changed, 727 insertions(+), 18 deletions(-)
create mode 100644 common/ip.go
create mode 100644 common/ssrf_protection.go
rename service/{cf_worker.go => download.go} (52%)
create mode 100644 setting/system_setting/fetch_setting.go
diff --git a/common/ip.go b/common/ip.go
new file mode 100644
index 00000000..bfb64ee7
--- /dev/null
+++ b/common/ip.go
@@ -0,0 +1,22 @@
+package common
+
+import "net"
+
+func IsPrivateIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+
+ private := []net.IPNet{
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
+ }
+
+ for _, privateNet := range private {
+ if privateNet.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
new file mode 100644
index 00000000..b0988d90
--- /dev/null
+++ b/common/ssrf_protection.go
@@ -0,0 +1,384 @@
+package common
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// SSRFProtection SSRF防护配置
+type SSRFProtection struct {
+ AllowPrivateIp bool
+ WhitelistDomains []string // domain format, e.g. example.com, *.example.com
+ WhitelistIps []string // CIDR format
+ AllowedPorts []int // 允许的端口范围
+}
+
+// DefaultSSRFProtection 默认SSRF防护配置
+var DefaultSSRFProtection = &SSRFProtection{
+ AllowPrivateIp: false,
+ WhitelistDomains: []string{},
+ WhitelistIps: []string{},
+ AllowedPorts: []int{},
+}
+
+// isPrivateIP 检查IP是否为私有地址
+func isPrivateIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+
+ // 检查私有网段
+ private := []net.IPNet{
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
+ {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
+ {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
+ {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
+ {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
+ }
+
+ for _, privateNet := range private {
+ if privateNet.Contains(ip) {
+ return true
+ }
+ }
+
+ // 检查IPv6私有地址
+ if ip.To4() == nil {
+ // IPv6 loopback
+ if ip.Equal(net.IPv6loopback) {
+ return true
+ }
+ // IPv6 link-local
+ if strings.HasPrefix(ip.String(), "fe80:") {
+ return true
+ }
+ // IPv6 unique local
+ if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
+ return true
+ }
+ }
+
+ return false
+}
+
+// parsePortRanges 解析端口范围配置
+// 支持格式: "80", "443", "8000-9000"
+func parsePortRanges(portConfigs []string) ([]int, error) {
+ var ports []int
+
+ for _, config := range portConfigs {
+ config = strings.TrimSpace(config)
+ if config == "" {
+ continue
+ }
+
+ if strings.Contains(config, "-") {
+ // 处理端口范围 "8000-9000"
+ parts := strings.Split(config, "-")
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid port range format: %s", config)
+ }
+
+ startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
+ }
+
+ endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err != nil {
+ return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
+ }
+
+ if startPort > endPort {
+ return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
+ }
+
+ if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
+ return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
+ }
+
+ // 添加范围内的所有端口
+ for port := startPort; port <= endPort; port++ {
+ ports = append(ports, port)
+ }
+ } else {
+ // 处理单个端口 "80"
+ port, err := strconv.Atoi(config)
+ if err != nil {
+ return nil, fmt.Errorf("invalid port number: %s", config)
+ }
+
+ if port < 1 || port > 65535 {
+ return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
+ }
+
+ ports = append(ports, port)
+ }
+ }
+
+ return ports, nil
+}
+
+// isAllowedPort 检查端口是否被允许
+func (p *SSRFProtection) isAllowedPort(port int) bool {
+ if len(p.AllowedPorts) == 0 {
+ return true // 如果没有配置端口限制,则允许所有端口
+ }
+
+ for _, allowedPort := range p.AllowedPorts {
+ if port == allowedPort {
+ return true
+ }
+ }
+ return false
+}
+
+// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许
+func isAllowedPortFromRanges(port int, portRanges []string) bool {
+ if len(portRanges) == 0 {
+ return true // 如果没有配置端口限制,则允许所有端口
+ }
+
+ allowedPorts, err := parsePortRanges(portRanges)
+ if err != nil {
+ // 如果解析失败,为安全起见拒绝访问
+ return false
+ }
+
+ for _, allowedPort := range allowedPorts {
+ if port == allowedPort {
+ return true
+ }
+ }
+ return false
+}
+
+// isDomainWhitelisted 检查域名是否在白名单中
+func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
+ if len(p.WhitelistDomains) == 0 {
+ return false
+ }
+
+ domain = strings.ToLower(domain)
+ for _, whitelistDomain := range p.WhitelistDomains {
+ whitelistDomain = strings.ToLower(whitelistDomain)
+
+ // 精确匹配
+ if domain == whitelistDomain {
+ return true
+ }
+
+ // 通配符匹配 (*.example.com)
+ if strings.HasPrefix(whitelistDomain, "*.") {
+ suffix := strings.TrimPrefix(whitelistDomain, "*.")
+ if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// isIPWhitelisted 检查IP是否在白名单中
+func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
+ if len(p.WhitelistIps) == 0 {
+ return false
+ }
+
+ for _, whitelistCIDR := range p.WhitelistIps {
+ _, network, err := net.ParseCIDR(whitelistCIDR)
+ if err != nil {
+ // 尝试作为单个IP处理
+ if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
+ if ip.Equal(whitelistIP) {
+ return true
+ }
+ }
+ continue
+ }
+
+ if network.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// IsIPAccessAllowed 检查IP是否允许访问
+func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
+ // 如果IP在白名单中,直接允许访问(绕过私有IP检查)
+ if p.isIPWhitelisted(ip) {
+ return true
+ }
+
+ // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查)
+ if len(p.WhitelistIps) == 0 {
+ // 检查私有IP限制
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
+ return false
+ }
+ return true
+ }
+
+ // 如果IP白名单不为空且IP不在白名单中,拒绝访问
+ return false
+}
+
+// ValidateURL 验证URL是否安全
+func (p *SSRFProtection) ValidateURL(urlStr string) error {
+ // 解析URL
+ u, err := url.Parse(urlStr)
+ if err != nil {
+ return fmt.Errorf("invalid URL format: %v", err)
+ }
+
+ // 只允许HTTP/HTTPS协议
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
+ }
+
+ // 解析主机和端口
+ host, portStr, err := net.SplitHostPort(u.Host)
+ if err != nil {
+ // 没有端口,使用默认端口
+ host = u.Host
+ if u.Scheme == "https" {
+ portStr = "443"
+ } else {
+ portStr = "80"
+ }
+ }
+
+ // 验证端口
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return fmt.Errorf("invalid port: %s", portStr)
+ }
+
+ if !p.isAllowedPort(port) {
+ return fmt.Errorf("port %d is not allowed", port)
+ }
+
+ // 检查域名白名单
+ if p.isDomainWhitelisted(host) {
+ return nil // 白名单域名直接通过
+ }
+
+ // DNS解析获取IP地址
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
+ }
+
+ // 检查所有解析的IP地址
+ for _, ip := range ips {
+ if !p.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
+ } else {
+ return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ }
+ }
+
+ return nil
+}
+
+// ValidateURLWithDefaults 使用默认配置验证URL
+func ValidateURLWithDefaults(urlStr string) error {
+ return DefaultSSRFProtection.ValidateURL(urlStr)
+}
+
+// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+ // 如果SSRF防护被禁用,直接返回成功
+ if !enableSSRFProtection {
+ return nil
+ }
+
+ // 解析端口范围配置
+ allowedPortInts, err := parsePortRanges(allowedPorts)
+ if err != nil {
+ return fmt.Errorf("request reject - invalid port configuration: %v", err)
+ }
+
+ protection := &SSRFProtection{
+ AllowPrivateIp: allowPrivateIp,
+ WhitelistDomains: whitelistDomains,
+ WhitelistIps: whitelistIps,
+ AllowedPorts: allowedPortInts,
+ }
+ return protection.ValidateURL(urlStr)
+}
+
+// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本)
+func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+ // 解析URL
+ u, err := url.Parse(urlStr)
+ if err != nil {
+ return fmt.Errorf("invalid URL format: %v", err)
+ }
+
+ // 只允许HTTP/HTTPS协议
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
+ }
+
+ // 解析主机和端口
+ host, portStr, err := net.SplitHostPort(u.Host)
+ if err != nil {
+ // 没有端口,使用默认端口
+ host = u.Host
+ if u.Scheme == "https" {
+ portStr = "443"
+ } else {
+ portStr = "80"
+ }
+ }
+
+ // 验证端口
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return fmt.Errorf("invalid port: %s", portStr)
+ }
+
+ if !isAllowedPortFromRanges(port, allowedPorts) {
+ return fmt.Errorf("port %d is not allowed", port)
+ }
+
+ // 创建临时的SSRFProtection来复用域名和IP检查逻辑
+ protection := &SSRFProtection{
+ AllowPrivateIp: allowPrivateIp,
+ WhitelistDomains: whitelistDomains,
+ WhitelistIps: whitelistIps,
+ }
+
+ // 检查域名白名单
+ if protection.isDomainWhitelisted(host) {
+ return nil // 白名单域名直接通过
+ }
+
+ // DNS解析获取IP地址
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
+ }
+
+ // 检查所有解析的IP地址
+ for _, ip := range ips {
+ if !protection.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
+ } else {
+ return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/service/cf_worker.go b/service/download.go
similarity index 52%
rename from service/cf_worker.go
rename to service/download.go
index 4a7b4376..2f30870d 100644
--- a/service/cf_worker.go
+++ b/service/download.go
@@ -6,7 +6,7 @@ import (
"fmt"
"net/http"
"one-api/common"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -21,14 +21,20 @@ type WorkerRequest struct {
// DoWorkerRequest 通过Worker发送请求
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
- if !setting.EnableWorker() {
+ if !system_setting.EnableWorker() {
return nil, fmt.Errorf("worker not enabled")
}
- if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
+ if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
return nil, fmt.Errorf("only support https url")
}
- workerUrl := setting.WorkerUrl
+ // SSRF防护:验证请求URL
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return nil, fmt.Errorf("request reject: %v", err)
+ }
+
+ workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
}
@@ -43,15 +49,21 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
}
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
req := &WorkerRequest{
URL: originUrl,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
}
return DoWorkerRequest(req)
} else {
- common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
+ // SSRF防护:验证请求URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return nil, fmt.Errorf("request reject: %v", err)
+ }
+
+ common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
return http.Get(originUrl)
}
}
diff --git a/service/user_notify.go b/service/user_notify.go
index c4a3ea91..f9d7b669 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -7,7 +7,7 @@ import (
"one-api/common"
"one-api/dto"
"one-api/model"
- "one-api/setting"
+ "one-api/setting/system_setting"
"strings"
)
@@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
var resp *http.Response
var err error
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 使用worker发送请求
workerReq := &WorkerRequest{
URL: finalURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodGet,
Headers: map[string]string{
"User-Agent": "OneAPI-Bark-Notify/1.0",
@@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
}
} else {
+ // SSRF防护:验证Bark URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return fmt.Errorf("request reject: %v", err)
+ }
+
// 直接发送请求
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
if err != nil {
diff --git a/service/webhook.go b/service/webhook.go
index 8faccda3..1f159eb4 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -8,8 +8,9 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "one-api/common"
"one-api/dto"
- "one-api/setting"
+ "one-api/setting/system_setting"
"time"
)
@@ -56,11 +57,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
var req *http.Request
var resp *http.Response
- if setting.EnableWorker() {
+ if system_setting.EnableWorker() {
// 构建worker请求数据
workerReq := &WorkerRequest{
URL: webhookURL,
- Key: setting.WorkerValidKey,
+ Key: system_setting.WorkerValidKey,
Method: http.MethodPost,
Headers: map[string]string{
"Content-Type": "application/json",
@@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
}
} else {
+ // SSRF防护:验证Webhook URL(非Worker模式)
+ fetchSetting := system_setting.GetFetchSetting()
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ return fmt.Errorf("request reject: %v", err)
+ }
+
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create webhook request: %v", err)
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
new file mode 100644
index 00000000..6e47c3f0
--- /dev/null
+++ b/setting/system_setting/fetch_setting.go
@@ -0,0 +1,28 @@
+package system_setting
+
+import "one-api/setting/config"
+
+type FetchSetting struct {
+ EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
+ AllowPrivateIp bool `json:"allow_private_ip"`
+ WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com
+ WhitelistIps []string `json:"whitelist_ips"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+}
+
+var defaultFetchSetting = FetchSetting{
+ EnableSSRFProtection: true, // 默认开启SSRF防护
+ AllowPrivateIp: false,
+ WhitelistDomains: []string{},
+ WhitelistIps: []string{},
+ AllowedPorts: []string{"80", "443", "8080", "8443"},
+}
+
+func init() {
+ // 注册到全局配置管理器
+ config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting)
+}
+
+func GetFetchSetting() *FetchSetting {
+ return &defaultFetchSetting
+}
diff --git a/types/error.go b/types/error.go
index 883ee064..a42e8438 100644
--- a/types/error.go
+++ b/types/error.go
@@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string {
return string(e.errorCode)
}
errStr := e.Err.Error()
+ if e.errorCode == ErrorCodeCountTokenFailed {
+ return errStr
+ }
return common.MaskSensitiveInfo(errStr)
}
@@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
Code: e.errorCode,
}
}
-
- result.Message = common.MaskSensitiveInfo(result.Message)
+ if e.errorCode != ErrorCodeCountTokenFailed {
+ result.Message = common.MaskSensitiveInfo(result.Message)
+ }
return result
}
@@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
Type: string(e.errorType),
}
}
- result.Message = common.MaskSensitiveInfo(result.Message)
+ if e.errorCode != ErrorCodeCountTokenFailed {
+ result.Message = common.MaskSensitiveInfo(result.Message)
+ }
return result
}
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 9c7eeaad..71dfaac8 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -44,6 +44,7 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
+
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
@@ -87,6 +88,12 @@ const SystemSetting = () => {
LinuxDOClientSecret: '',
LinuxDOMinimumTrustLevel: '',
ServerAddress: '',
+ // SSRF防护配置
+ 'fetch_setting.enable_ssrf_protection': true,
+ 'fetch_setting.allow_private_ip': '',
+ 'fetch_setting.whitelist_domains': [],
+ 'fetch_setting.whitelist_ips': [],
+ 'fetch_setting.allowed_ports': [],
});
const [originInputs, setOriginInputs] = useState({});
@@ -98,6 +105,9 @@ const SystemSetting = () => {
useState(false);
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
const [emailToAdd, setEmailToAdd] = useState('');
+ const [whitelistDomains, setWhitelistDomains] = useState([]);
+ const [whitelistIps, setWhitelistIps] = useState([]);
+ const [allowedPorts, setAllowedPorts] = useState([]);
const getOptions = async () => {
setLoading(true);
@@ -113,6 +123,34 @@ const SystemSetting = () => {
case 'EmailDomainWhitelist':
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
break;
+ case 'fetch_setting.allow_private_ip':
+ case 'fetch_setting.enable_ssrf_protection':
+ item.value = toBoolean(item.value);
+ break;
+ case 'fetch_setting.whitelist_domains':
+ try {
+ const domains = item.value ? JSON.parse(item.value) : [];
+ setWhitelistDomains(Array.isArray(domains) ? domains : []);
+ } catch (e) {
+ setWhitelistDomains([]);
+ }
+ break;
+ case 'fetch_setting.whitelist_ips':
+ try {
+ const ips = item.value ? JSON.parse(item.value) : [];
+ setWhitelistIps(Array.isArray(ips) ? ips : []);
+ } catch (e) {
+ setWhitelistIps([]);
+ }
+ break;
+ case 'fetch_setting.allowed_ports':
+ try {
+ const ports = item.value ? JSON.parse(item.value) : [];
+ setAllowedPorts(Array.isArray(ports) ? ports : []);
+ } catch (e) {
+ setAllowedPorts(['80', '443', '8080', '8443']);
+ }
+ break;
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
@@ -276,6 +314,38 @@ const SystemSetting = () => {
}
};
+ const submitSSRF = async () => {
+ const options = [];
+
+ // 处理域名白名单
+ if (Array.isArray(whitelistDomains)) {
+ options.push({
+ key: 'fetch_setting.whitelist_domains',
+ value: JSON.stringify(whitelistDomains),
+ });
+ }
+
+ // 处理IP白名单
+ if (Array.isArray(whitelistIps)) {
+ options.push({
+ key: 'fetch_setting.whitelist_ips',
+ value: JSON.stringify(whitelistIps),
+ });
+ }
+
+ // 处理端口配置
+ if (Array.isArray(allowedPorts)) {
+ options.push({
+ key: 'fetch_setting.allowed_ports',
+ value: JSON.stringify(allowedPorts),
+ });
+ }
+
+ if (options.length > 0) {
+ await updateOptions(options);
+ }
+ };
+
const handleAddEmail = () => {
if (emailToAdd && emailToAdd.trim() !== '') {
const domain = emailToAdd.trim();
@@ -587,6 +657,136 @@ const SystemSetting = () => {
+
+
+
+ {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')}
+
+
+
+
+ handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
+ }
+ >
+ {t('启用SSRF防护(推荐开启以保护服务器安全)')}
+
+
+
+
+
+
+
+ handleCheckboxChange('fetch_setting.allow_private_ip', e)
+ }
+ >
+ {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
+
+
+
+
+
+
+ {t('域名白名单')}
+
+ {t('支持通配符格式,如:example.com, *.api.example.com')}
+
+ {
+ setWhitelistDomains(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.whitelist_domains': value
+ }));
+ }}
+ placeholder={t('输入域名后回车,如:example.com')}
+ style={{ width: '100%' }}
+ />
+
+ {t('域名白名单详细说明')}
+
+
+
+
+
+
+ {t('IP白名单')}
+
+ {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
+
+ {
+ setWhitelistIps(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.whitelist_ips': value
+ }));
+ }}
+ placeholder={t('输入IP地址后回车,如:8.8.8.8')}
+ style={{ width: '100%' }}
+ />
+
+ {t('IP白名单详细说明')}
+
+
+
+
+
+
+ {t('允许的端口')}
+
+ {t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
+
+ {
+ setAllowedPorts(value);
+ // 触发Form的onChange事件
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.allowed_ports': value
+ }));
+ }}
+ placeholder={t('输入端口后回车,如:80 或 8000-8999')}
+ style={{ width: '100%' }}
+ />
+
+ {t('端口配置详细说明')}
+
+
+
+
+
+
+
+
Date: Mon, 15 Sep 2025 01:01:48 +0800
Subject: [PATCH 08/89] fix: gemini support webp file
---
relay/channel/gemini/relay-gemini.go | 2 ++
service/pre_consume_quota.go | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index eb4afbae..199c8466 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -23,6 +23,7 @@ import (
"github.com/gin-gonic/gin"
)
+// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob
var geminiSupportedMimeTypes = map[string]bool{
"application/pdf": true,
"audio/mpeg": true,
@@ -30,6 +31,7 @@ var geminiSupportedMimeTypes = map[string]bool{
"audio/wav": true,
"image/png": true,
"image/jpeg": true,
+ "image/webp": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go
index 3cfabc1a..0cf53513 100644
--- a/service/pre_consume_quota.go
+++ b/service/pre_consume_quota.go
@@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
gopool.Go(func() {
relayInfoCopy := *relayInfo
- err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
+ err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
if err != nil {
common.SysLog("error return pre-consumed quota: " + err.Error())
}
From 6b028bfedb1513e83bc17e6bd88c199b0af3e7f7 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Mon, 15 Sep 2025 14:31:55 +0800
Subject: [PATCH 09/89] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8D=B3?=
=?UTF-8?q?=E6=A2=A6=E8=A7=86=E9=A2=913.0,=E6=96=B0=E5=A2=9E10s(frames=3D2?=
=?UTF-8?q?41)=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/task/jimeng/adaptor.go | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index 2bc45c54..e870a659 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -36,6 +36,7 @@ type requestPayload struct {
Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed"`
AspectRatio string `json:"aspect_ratio"`
+ Frames int `json:"frames,omitempty"`
}
type responsePayload struct {
@@ -311,10 +312,15 @@ func hmacSHA256(key []byte, data []byte) []byte {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{
- ReqKey: "jimeng_vgfm_i2v_l20",
- Prompt: req.Prompt,
- AspectRatio: "16:9", // Default aspect ratio
- Seed: -1, // Default to random
+ ReqKey: req.Model,
+ Prompt: req.Prompt,
+ }
+
+ switch req.Duration {
+ case 10:
+ r.Frames = 241 // 24*10+1 = 241
+ default:
+ r.Frames = 121 // 24*5+1 = 121
}
// Handle one-of image_urls or binary_data_base64
From cf446438ed4ab41b19e97e65dc8cc7f29e5eac90 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Mon, 15 Sep 2025 15:53:41 +0800
Subject: [PATCH 10/89] feat: jimeng video 3.0 req_key convert
---
relay/channel/task/jimeng/adaptor.go | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index e870a659..b954d7b8 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -340,6 +340,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
+
+ // 即梦视频3.0 ReqKey转换
+ // https://www.volcengine.com/docs/85621/1792707
+ if strings.Contains(r.ReqKey, "jimeng_v30") {
+ if len(r.ImageUrls) > 1 {
+ // 多张图片:首尾帧生成
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
+ } else if len(r.ImageUrls) == 1 {
+ // 单张图片:图生视频
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
+ } else {
+ // 无图片:文生视频
+ r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
+ }
+ }
+
return &r, nil
}
From 8f5c29342dba75e470b359deee7404b056b46f9a Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Mon, 15 Sep 2025 16:22:37 +0800
Subject: [PATCH 11/89] =?UTF-8?q?fix:=20stripe=E6=94=AF=E4=BB=98=E6=88=90?=
=?UTF-8?q?=E5=8A=9F=E6=9C=AA=E6=AD=A3=E7=A1=AE=E8=B7=B3=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/topup_stripe.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go
index d462acb4..ccde91db 100644
--- a/controller/topup_stripe.go
+++ b/controller/topup_stripe.go
@@ -217,7 +217,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
- SuccessURL: stripe.String(system_setting.ServerAddress + "/log"),
+ SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
From c176e713f71118223d66d1eabf5822271e95241e Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Mon, 15 Sep 2025 19:38:31 +0800
Subject: [PATCH 12/89] =?UTF-8?q?fix:=20=E9=9D=9Eopenai=20=E6=B8=A0?=
=?UTF-8?q?=E9=81=93=E4=BD=BF=E7=94=A8=20SystemPrompt=20=E8=AE=BE=E7=BD=AE?=
=?UTF-8?q?=E4=BC=9Apanic?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/compatible_handler.go | 64 +++++++++++++++++++------------------
1 file changed, 33 insertions(+), 31 deletions(-)
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 01ab1fff..c2d6b6fa 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -90,41 +90,43 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if info.ChannelSetting.SystemPrompt != "" {
// 如果有系统提示,则将其添加到请求中
- request := convertedRequest.(*dto.GeneralOpenAIRequest)
- containSystemPrompt := false
- for _, message := range request.Messages {
- if message.Role == request.GetSystemRoleName() {
- containSystemPrompt = true
- break
- }
- }
- if !containSystemPrompt {
- // 如果没有系统提示,则添加系统提示
- systemMessage := dto.Message{
- Role: request.GetSystemRoleName(),
- Content: info.ChannelSetting.SystemPrompt,
- }
- request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
- } else if info.ChannelSetting.SystemPromptOverride {
- common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
- // 如果有系统提示,且允许覆盖,则拼接到前面
- for i, message := range request.Messages {
+ request, ok := convertedRequest.(*dto.GeneralOpenAIRequest)
+ if ok {
+ containSystemPrompt := false
+ for _, message := range request.Messages {
if message.Role == request.GetSystemRoleName() {
- if message.IsStringContent() {
- request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
- } else {
- contents := message.ParseContent()
- contents = append([]dto.MediaContent{
- {
- Type: dto.ContentTypeText,
- Text: info.ChannelSetting.SystemPrompt,
- },
- }, contents...)
- request.Messages[i].Content = contents
- }
+ containSystemPrompt = true
break
}
}
+ if !containSystemPrompt {
+ // 如果没有系统提示,则添加系统提示
+ systemMessage := dto.Message{
+ Role: request.GetSystemRoleName(),
+ Content: info.ChannelSetting.SystemPrompt,
+ }
+ request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
+ } else if info.ChannelSetting.SystemPromptOverride {
+ common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+ // 如果有系统提示,且允许覆盖,则拼接到前面
+ for i, message := range request.Messages {
+ if message.Role == request.GetSystemRoleName() {
+ if message.IsStringContent() {
+ request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
+ } else {
+ contents := message.ParseContent()
+ contents = append([]dto.MediaContent{
+ {
+ Type: dto.ContentTypeText,
+ Text: info.ChannelSetting.SystemPrompt,
+ },
+ }, contents...)
+ request.Messages[i].Content = contents
+ }
+ break
+ }
+ }
+ }
}
}
From 2836ec2eb393f5890c3d3c2ea485baf8e14a82ef Mon Sep 17 00:00:00 2001
From: QuentinHsu
Date: Mon, 15 Sep 2025 21:45:00 +0800
Subject: [PATCH 13/89] feat: add date range preset constants and use them in
the log filter
---
.../table/mj-logs/MjLogsFilters.jsx | 7 +++
.../table/task-logs/TaskLogsFilters.jsx | 7 +++
.../table/usage-logs/UsageLogsFilters.jsx | 7 +++
web/src/constants/console.constants.js | 49 +++++++++++++++++++
web/src/i18n/locales/en.json | 7 ++-
5 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 web/src/constants/console.constants.js
diff --git a/web/src/components/table/mj-logs/MjLogsFilters.jsx b/web/src/components/table/mj-logs/MjLogsFilters.jsx
index 44c6bcfc..6db96e79 100644
--- a/web/src/components/table/mj-logs/MjLogsFilters.jsx
+++ b/web/src/components/table/mj-logs/MjLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const MjLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/components/table/task-logs/TaskLogsFilters.jsx b/web/src/components/table/task-logs/TaskLogsFilters.jsx
index d5e081ab..e27cea86 100644
--- a/web/src/components/table/task-logs/TaskLogsFilters.jsx
+++ b/web/src/components/table/task-logs/TaskLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const TaskLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/components/table/usage-logs/UsageLogsFilters.jsx b/web/src/components/table/usage-logs/UsageLogsFilters.jsx
index f76ec823..58e5a469 100644
--- a/web/src/components/table/usage-logs/UsageLogsFilters.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsFilters.jsx
@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
const LogsFilters = ({
formInitValues,
setFormApi,
@@ -55,6 +57,11 @@ const LogsFilters = ({
showClear
pure
size='small'
+ presets={DATE_RANGE_PRESETS.map(preset => ({
+ text: t(preset.text),
+ start: preset.start(),
+ end: preset.end()
+ }))}
/>
diff --git a/web/src/constants/console.constants.js b/web/src/constants/console.constants.js
new file mode 100644
index 00000000..23ee1e17
--- /dev/null
+++ b/web/src/constants/console.constants.js
@@ -0,0 +1,49 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import dayjs from 'dayjs';
+
+// ========== 日期预设常量 ==========
+export const DATE_RANGE_PRESETS = [
+ {
+ text: '今天',
+ start: () => dayjs().startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '近 7 天',
+ start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '本周',
+ start: () => dayjs().startOf('week').toDate(),
+ end: () => dayjs().endOf('week').toDate()
+ },
+ {
+ text: '近 30 天',
+ start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
+ end: () => dayjs().endOf('day').toDate()
+ },
+ {
+ text: '本月',
+ start: () => dayjs().startOf('month').toDate(),
+ end: () => dayjs().endOf('month').toDate()
+ },
+];
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 73dfbebe..a527b91c 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2084,5 +2084,10 @@
"原价": "Original price",
"优惠": "Discount",
"折": "% off",
- "节省": "Save"
+ "节省": "Save",
+ "今天": "Today",
+ "近 7 天": "Last 7 Days",
+ "本周": "This Week",
+ "本月": "This Month",
+ "近 30 天": "Last 30 Days"
}
From fc4660f403771a2577521184d7abcf6c97022ad0 Mon Sep 17 00:00:00 2001
From: QuentinHsu
Date: Mon, 15 Sep 2025 22:30:41 +0800
Subject: [PATCH 14/89] feat: add jsconfig.json and configure path aliases
---
web/jsconfig.json | 9 +++++++++
web/vite.config.js | 6 ++++++
2 files changed, 15 insertions(+)
create mode 100644 web/jsconfig.json
diff --git a/web/jsconfig.json b/web/jsconfig.json
new file mode 100644
index 00000000..ced4d054
--- /dev/null
+++ b/web/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*"]
+}
\ No newline at end of file
diff --git a/web/vite.config.js b/web/vite.config.js
index 3515dce7..d57fd9d9 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
import react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
+import path from 'path';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
plugins: [
{
name: 'treat-js-files-as-jsx',
From 767d46e829605ca225666a21b043377e5afa9c33 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:01:14 +0800
Subject: [PATCH 15/89] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84ollama=E6=B8=A0?=
=?UTF-8?q?=E9=81=93=E8=AF=B7=E6=B1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 31 ++--
relay/channel/ollama/dto.go | 91 +++++----
relay/channel/ollama/relay-ollama.go | 263 ++++++++++++++++-----------
relay/channel/ollama/stream.go | 165 +++++++++++++++++
4 files changed, 400 insertions(+), 150 deletions(-)
create mode 100644 relay/channel/ollama/stream.go
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index d6b5b697..3732be91 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -10,6 +10,7 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -48,15 +49,15 @@ 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:
+ // embeddings fixed endpoint
+ if info.RelayMode == relayconstant.RelayModeEmbeddings {
return info.ChannelBaseUrl + "/api/embed", nil
- default:
- return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
+ // For chat vs generate: if original path contains "/v1/completions" map to generate; otherwise chat
+ 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 +67,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) {
@@ -92,15 +95,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 {
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index 317c2a4a..b3d083dc 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -5,45 +5,70 @@ import (
"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"`
+// OllamaChatMessage represents a single chat message
+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"`
+}
+
+// OllamaChatRequest -> /api/chat
+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"`
+}
+
+// OllamaGenerateRequest -> /api/generate
+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"`
}
+
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 27c67b4e..897e22cb 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -1,6 +1,7 @@
package ollama
import (
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -14,121 +15,179 @@ 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
+// openAIChatToOllamaChat converts OpenAI-style chat request to Ollama chat
+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,
+ }
+ // format mapping
+ if r.ResponseFormat != nil {
+ if r.ResponseFormat.Type == "json" {
+ chatReq.Format = "json"
+ } else if r.ResponseFormat.Type == "json_schema" {
+ // supply schema object directly
+ 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) }
+
+ // Stop -> options.stop (array)
+ 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 }
+ }
+ }
+
+ // tools
+ 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
+ }
+
+ // messages
+ chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
+ for _, m := range r.Messages {
+ // gather text parts & images
+ 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 != "" {
+ // ensure base64 dataURL
+ if strings.HasPrefix(img.Url, "http") {
+ fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
+ if err != nil { return nil, err }
+ img.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
}
- imageUrl.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
+ images = append(images, img.Url)
}
- 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 }
+ // history tool call result message
+ if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
+ // tool calls from assistant previous message
+ if len(m.ToolCalls)>0 {
+ calls := make([]OllamaToolCall,0,len(m.ToolCalls))
+ for _, tc := range m.ToolCalls {
+ var args interface{}
+ if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
+ oc := OllamaToolCall{}
+ oc.Function.Name = tc.Function.Name
+ if args==nil { args = map[string]any{} }
+ 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.TopK != 0 { opts["top_k"] = r.TopK }
+ 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
-}
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
new file mode 100644
index 00000000..3ae9c6d0
--- /dev/null
+++ b/relay/channel/ollama/stream.go
@@ -0,0 +1,165 @@
+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"
+)
+
+// Ollama streaming chunk (chat or generate)
+type ollamaChatStreamChunk struct {
+ Model string `json:"model"`
+ CreatedAt string `json:"created_at"`
+ // chat
+ Message *struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []struct { `json:"tool_calls"`
+ 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"`
+ // generate mode may use these
+ PromptEvalDuration int64 `json:"prompt_eval_duration"`
+ EvalDuration int64 `json:"eval_duration"`
+}
+
+func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
+ 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()
+}
+
+// streaming handler: convert Ollama stream -> OpenAI SSE
+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 aggregatedText strings.Builder
+ var toolCallIndex int
+ // send start event
+ 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 }
+ if content != "" { aggregatedText.WriteString(content) }
+ 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) }
+ // 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)
+ tr := dto.ToolCallResponse{ID:"", Type:nil, 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
+ usage.PromptTokens = chunk.PromptEvalCount
+ usage.CompletionTokens = chunk.EvalCount
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ finishReason := chunk.DoneReason
+ if finishReason == "" { finishReason = "stop" }
+ stop := helper.GenerateStopResponse(responseId, created, model, finishReason)
+ if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
+ final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage)
+ if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ }
+ 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)
+ if common.DebugEnabled { println("ollama non-stream resp:", string(body)) }
+ var chunk ollamaChatStreamChunk
+ if err = json.Unmarshal(body, &chunk); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ model := chunk.Model
+ if model == "" { model = info.UpstreamModelName }
+ created := toUnix(chunk.CreatedAt)
+ content := ""
+ if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
+ usage := &dto.Usage{PromptTokens: chunk.PromptEvalCount, CompletionTokens: chunk.EvalCount, TotalTokens: chunk.PromptEvalCount + chunk.EvalCount}
+ // Build OpenAI style response
+ full := dto.OpenAITextResponse{
+ Id: common.GetUUID(),
+ Model: model,
+ Object: "chat.completion",
+ Created: created,
+ Choices: []dto.OpenAITextResponseChoice{ {
+ Index: 0,
+ Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
+ FinishReason: func() string { if chunk.DoneReason == "" { return "stop" } ; return chunk.DoneReason }(),
+ } },
+ 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 }
From cdf0c9dae483ba68e49159f2ab3672fd6158f213 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:09:10 +0800
Subject: [PATCH 16/89] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96ollamaChatStream?=
=?UTF-8?q?Chunk=E7=BB=93=E6=9E=84=E4=BD=93=E5=AD=97=E6=AE=B5=E6=A0=BC?=
=?UTF-8?q?=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 3ae9c6d0..db615e8b 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -25,9 +25,9 @@ type ollamaChatStreamChunk struct {
CreatedAt string `json:"created_at"`
// chat
Message *struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []struct { `json:"tool_calls"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments interface{} `json:"arguments"`
@@ -66,7 +66,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var model = info.UpstreamModelName
var responseId = common.GetUUID()
var created = time.Now().Unix()
- var aggregatedText strings.Builder
var toolCallIndex int
// send start event
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
From 503a2669d5242eccbd01b472e7e79c1c9ba763a7 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:15:46 +0800
Subject: [PATCH 17/89] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E8=BD=AC=E6=8D=A2=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 3 ++-
relay/channel/ollama/dto.go | 1 -
relay/channel/ollama/relay-ollama.go | 26 ++++++++++++++------------
relay/channel/ollama/stream.go | 1 -
4 files changed, 16 insertions(+), 15 deletions(-)
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index 3732be91..d66839f7 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -32,7 +32,8 @@ 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) {
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index b3d083dc..a3e325e2 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -2,7 +2,6 @@ package ollama
import (
"encoding/json"
- "one-api/dto"
)
// OllamaChatMessage represents a single chat message
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 897e22cb..45424633 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -101,18 +101,21 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
// history tool call result message
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
// tool calls from assistant previous message
- if len(m.ToolCalls)>0 {
- calls := make([]OllamaToolCall,0,len(m.ToolCalls))
- for _, tc := range m.ToolCalls {
- var args interface{}
- if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
- oc := OllamaToolCall{}
- oc.Function.Name = tc.Function.Name
- if args==nil { args = map[string]any{} }
- oc.Function.Arguments = args
- calls = append(calls, oc)
+ 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
}
- cm.ToolCalls = calls
}
chatReq.Messages = append(chatReq.Messages, cm)
}
@@ -165,7 +168,6 @@ 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.TopK != 0 { opts["top_k"] = r.TopK }
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) }
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index db615e8b..d5b104d6 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -87,7 +87,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// delta content
var content string
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- if content != "" { aggregatedText.WriteString(content) }
delta := dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
From a9354df54920dcf6d0727eec6655fdf8cda21bb9 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:23:53 +0800
Subject: [PATCH 18/89] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96ollamaStreamHand?=
=?UTF-8?q?ler=E4=B8=AD=E7=9A=84=E5=81=9C=E6=AD=A2=E5=92=8C=E6=9C=80?=
=?UTF-8?q?=E7=BB=88=E4=BD=BF=E7=94=A8=E5=93=8D=E5=BA=94=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index d5b104d6..4e17f12d 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -114,15 +114,23 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
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" }
- stop := helper.GenerateStopResponse(responseId, created, model, finishReason)
- if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
- final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage)
- if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
+ // 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
From a1f79761cfaddb68180440577dd829ac7a79bc7d Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:41:09 +0800
Subject: [PATCH 19/89] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E8=AF=B7?=
=?UTF-8?q?=E6=B1=82=E8=BD=AC=E6=8D=A2=E5=87=BD=E6=95=B0=E5=92=8C=E6=B5=81?=
=?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/adaptor.go | 32 ++++------------
relay/channel/ollama/dto.go | 3 --
relay/channel/ollama/relay-ollama.go | 9 -----
relay/channel/ollama/stream.go | 56 +++++++++++++++++++++-------
4 files changed, 49 insertions(+), 51 deletions(-)
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index d66839f7..bafe73b9 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -18,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{}
@@ -36,29 +33,17 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
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) {
- // embeddings fixed endpoint
- if info.RelayMode == relayconstant.RelayModeEmbeddings {
- return info.ChannelBaseUrl + "/api/embed", nil
- }
- // For chat vs generate: if original path contains "/v1/completions" map to generate; otherwise chat
- if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
- return info.ChannelBaseUrl + "/api/generate", nil
- }
- return info.ChannelBaseUrl + "/api/chat", 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 {
@@ -84,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)
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index a3e325e2..45e49ab4 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
)
-// OllamaChatMessage represents a single chat message
type OllamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
@@ -32,7 +31,6 @@ type OllamaToolCall struct {
} `json:"function"`
}
-// OllamaChatRequest -> /api/chat
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaChatMessage `json:"messages"`
@@ -44,7 +42,6 @@ type OllamaChatRequest struct {
Think json.RawMessage `json:"think,omitempty"`
}
-// OllamaGenerateRequest -> /api/generate
type OllamaGenerateRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 45424633..c79f9876 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -15,7 +15,6 @@ import (
"github.com/gin-gonic/gin"
)
-// openAIChatToOllamaChat converts OpenAI-style chat request to Ollama chat
func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
chatReq := &OllamaChatRequest{
Model: r.Model,
@@ -23,12 +22,10 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
Options: map[string]any{},
Think: r.Think,
}
- // format mapping
if r.ResponseFormat != nil {
if r.ResponseFormat.Type == "json" {
chatReq.Format = "json"
} else if r.ResponseFormat.Type == "json_schema" {
- // supply schema object directly
if len(r.ResponseFormat.JsonSchema) > 0 {
var schema any
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
@@ -46,7 +43,6 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
- // Stop -> options.stop (array)
if r.Stop != nil {
switch v := r.Stop.(type) {
case string:
@@ -60,7 +56,6 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
}
- // tools
if len(r.Tools) > 0 {
tools := make([]OllamaTool,0,len(r.Tools))
for _, t := range r.Tools {
@@ -69,10 +64,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
chatReq.Tools = tools
}
- // messages
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
for _, m := range r.Messages {
- // gather text parts & images
var textBuilder strings.Builder
var images []string
if m.IsStringContent() {
@@ -98,9 +91,7 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
}
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
if len(images)>0 { cm.Images = images }
- // history tool call result message
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
- // tool calls from assistant previous message
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
parsed := m.ParseToolCalls()
if len(parsed) > 0 {
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 4e17f12d..167c676d 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -19,7 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
-// Ollama streaming chunk (chat or generate)
type ollamaChatStreamChunk struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
@@ -47,7 +46,7 @@ type ollamaChatStreamChunk struct {
EvalDuration int64 `json:"eval_duration"`
}
-func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
+func toUnix(ts string) int64 {
if ts == "" { return time.Now().Unix() }
// try time.RFC3339 or with nanoseconds
t, err := time.Parse(time.RFC3339Nano, ts)
@@ -55,7 +54,6 @@ func toUnix(ts string) int64 { // parse RFC3339 / variant; fallback time.Now
return t.Unix()
}
-// streaming handler: convert Ollama stream -> OpenAI SSE
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)
@@ -67,7 +65,6 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var responseId = common.GetUUID()
var created = time.Now().Unix()
var toolCallIndex int
- // send start event
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
@@ -141,16 +138,47 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
- if common.DebugEnabled { println("ollama non-stream resp:", string(body)) }
- var chunk ollamaChatStreamChunk
- if err = json.Unmarshal(body, &chunk); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
- model := chunk.Model
+ raw := string(body)
+ if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
+
+ lines := strings.Split(raw, "\n")
+ var (
+ aggContent 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.Done {
+ if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ } else {
+ 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 { aggContent.WriteString(single.Message.Content) } else { aggContent.WriteString(single.Response) }
+ }
+
+ model := lastChunk.Model
if model == "" { model = info.UpstreamModelName }
- created := toUnix(chunk.CreatedAt)
- content := ""
- if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
- usage := &dto.Usage{PromptTokens: chunk.PromptEvalCount, CompletionTokens: chunk.EvalCount, TotalTokens: chunk.PromptEvalCount + chunk.EvalCount}
- // Build OpenAI style response
+ 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" }
+
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
@@ -159,7 +187,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
- FinishReason: func() string { if chunk.DoneReason == "" { return "stop" } ; return chunk.DoneReason }(),
+ FinishReason: &finishReason,
} },
Usage: *usage,
}
From f57c539a1fbd2428289619979c16442b1b6c3d53 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Mon, 15 Sep 2025 23:43:39 +0800
Subject: [PATCH 20/89] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DollamaChatHandle?=
=?UTF-8?q?r=E4=B8=AD=E7=9A=84FinishReason=E5=AD=97=E6=AE=B5=E8=B5=8B?=
=?UTF-8?q?=E5=80=BC=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index 167c676d..ad12e7f8 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -187,7 +187,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
- FinishReason: &finishReason,
+ FinishReason: finishReason,
} },
Usage: *usage,
}
From b80811a4124c7392f1bddc7237c91bc1276d47a1 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:13:28 +0800
Subject: [PATCH 21/89] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?=
=?UTF-8?q?=E5=83=8FURL=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C?=
=?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=AD=A3=E7=A1=AE=E7=94=9F=E6=88=90base64?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/relay-ollama.go | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index c79f9876..3b67f952 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -76,13 +76,17 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
- // ensure base64 dataURL
+ 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 }
- img.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
+ 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
}
- images = append(images, img.Url)
+ if base64Data != "" { images = append(images, base64Data) }
}
} else if part.Type == dto.ContentTypeText {
textBuilder.WriteString(part.Text)
From 414b959e6555332a093062dc9056a9c9b43249c9 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:51:29 +0800
Subject: [PATCH 22/89] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E5=AF=B9Thinkin?=
=?UTF-8?q?g=E5=AD=97=E6=AE=B5=E7=9A=84=E5=A4=84=E7=90=86=E9=80=BB?=
=?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=8E=A8=E7=90=86=E5=86=85?=
=?UTF-8?q?=E5=AE=B9=E6=AD=A3=E7=A1=AE=E4=BC=A0=E9=80=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 31 +++++++++++++++++++++----------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index ad12e7f8..cea45844 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -26,6 +26,7 @@ type ollamaChatStreamChunk struct {
Message *struct {
Role string `json:"role"`
Content string `json:"content"`
+ Thinking json.RawMessage `json:"thinking"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
@@ -41,7 +42,6 @@ type ollamaChatStreamChunk struct {
LoadDuration int64 `json:"load_duration"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
- // generate mode may use these
PromptEvalDuration int64 `json:"prompt_eval_duration"`
EvalDuration int64 `json:"eval_duration"`
}
@@ -95,13 +95,18 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
} },
}
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)
- tr := dto.ToolCallResponse{ID:"", Type:nil, Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
+ 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)
@@ -115,8 +120,8 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokens = chunk.PromptEvalCount
usage.CompletionTokens = chunk.EvalCount
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
- finishReason := chunk.DoneReason
- if finishReason == "" { finishReason = "stop" }
+ 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)) }
@@ -144,6 +149,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
lines := strings.Split(raw, "\n")
var (
aggContent strings.Builder
+ reasoningBuilder strings.Builder
lastChunk ollamaChatStreamChunk
parsedAny bool
)
@@ -157,18 +163,21 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
parsedAny = true
lastChunk = ck
- if !ck.Done {
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
- } else {
- if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
+ 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 { aggContent.WriteString(single.Message.Content) } else { aggContent.WriteString(single.Response) }
+ 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
@@ -179,6 +188,8 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
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,
@@ -186,7 +197,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
Created: created,
Choices: []dto.OpenAITextResponseChoice{ {
Index: 0,
- Message: dto.Message{Role: "assistant", Content: contentPtr(content)},
+ Message: msg,
FinishReason: finishReason,
} },
Usage: *usage,
From 05241aea9265200fc674bc4f7717aea0616af8fb Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:54:34 +0800
Subject: [PATCH 23/89] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DollamaChatHandle?=
=?UTF-8?q?r=E4=B8=ADReasoningContent=E5=AD=97=E6=AE=B5=E7=9A=84=E8=B5=8B?=
=?UTF-8?q?=E5=80=BC=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/ollama/stream.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
index cea45844..964f11d9 100644
--- a/relay/channel/ollama/stream.go
+++ b/relay/channel/ollama/stream.go
@@ -189,7 +189,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
if finishReason == "" { finishReason = "stop" }
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
- if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = &rc }
+ if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
full := dto.OpenAITextResponse{
Id: common.GetUUID(),
Model: model,
From b48c292cc398ae4c1fddc30fb7014c90168e6df4 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:58:06 +0800
Subject: [PATCH 24/89] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0ollama=E8=81=8A?=
=?UTF-8?q?=E5=A4=A9=E6=B5=81=E5=A4=84=E7=90=86=E5=92=8C=E9=9D=9E=E6=B5=81?=
=?UTF-8?q?=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../channel/ollama/stream_20250916085416.go | 210 ++++++++++++++++++
.../channel/ollama/stream_20250916085435.go | 210 ++++++++++++++++++
2 files changed, 420 insertions(+)
create mode 100644 .history/relay/channel/ollama/stream_20250916085416.go
create mode 100644 .history/relay/channel/ollama/stream_20250916085435.go
diff --git a/.history/relay/channel/ollama/stream_20250916085416.go b/.history/relay/channel/ollama/stream_20250916085416.go
new file mode 100644
index 00000000..964f11d9
--- /dev/null
+++ b/.history/relay/channel/ollama/stream_20250916085416.go
@@ -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 }
diff --git a/.history/relay/channel/ollama/stream_20250916085435.go b/.history/relay/channel/ollama/stream_20250916085435.go
new file mode 100644
index 00000000..964f11d9
--- /dev/null
+++ b/.history/relay/channel/ollama/stream_20250916085435.go
@@ -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 }
From 8c6789656f4995aee284b39467b85723d30c9ed7 Mon Sep 17 00:00:00 2001
From: somnifex <98788152+somnifex@users.noreply.github.com>
Date: Tue, 16 Sep 2025 08:58:19 +0800
Subject: [PATCH 25/89] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E5=8E=86?=
=?UTF-8?q?=E5=8F=B2=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../channel/ollama/stream_20250916085416.go | 210 ------------------
.../channel/ollama/stream_20250916085435.go | 210 ------------------
2 files changed, 420 deletions(-)
delete mode 100644 .history/relay/channel/ollama/stream_20250916085416.go
delete mode 100644 .history/relay/channel/ollama/stream_20250916085435.go
diff --git a/.history/relay/channel/ollama/stream_20250916085416.go b/.history/relay/channel/ollama/stream_20250916085416.go
deleted file mode 100644
index 964f11d9..00000000
--- a/.history/relay/channel/ollama/stream_20250916085416.go
+++ /dev/null
@@ -1,210 +0,0 @@
-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 }
diff --git a/.history/relay/channel/ollama/stream_20250916085435.go b/.history/relay/channel/ollama/stream_20250916085435.go
deleted file mode 100644
index 964f11d9..00000000
--- a/.history/relay/channel/ollama/stream_20250916085435.go
+++ /dev/null
@@ -1,210 +0,0 @@
-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 }
From f6984272bf5dff6be6d59970d9125058be171296 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 12:47:59 +0800
Subject: [PATCH 26/89] =?UTF-8?q?fix:=20openai=20responses=20api=20?=
=?UTF-8?q?=E6=9C=AA=E7=BB=9F=E8=AE=A1=E5=9B=BE=E5=83=8F=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AE=A1=E8=B4=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/openai_response.go | 42 +++++++++++++++++++
relay/channel/openai/relay_responses.go | 35 +++++++++++-----
relay/compatible_handler.go | 13 ++++++
setting/operation_setting/tools.go | 40 ++++++++++++++++++
web/src/helpers/render.jsx | 23 +++++++++-
web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +
6 files changed, 142 insertions(+), 13 deletions(-)
diff --git a/dto/openai_response.go b/dto/openai_response.go
index 966748cb..6353c15f 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -6,6 +6,10 @@ import (
"one-api/types"
)
+const (
+ ResponsesOutputTypeImageGenerationCall = "image_generation_call"
+)
+
type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
@@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
+func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
+ if len(o.Output) == 0 {
+ return false
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return true
+ }
+ }
+ return false
+}
+
+func (o *OpenAIResponsesResponse) GetQuality() string {
+ if len(o.Output) == 0 {
+ return ""
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return output.Quality
+ }
+ }
+ return ""
+}
+
+func (o *OpenAIResponsesResponse) GetSize() string {
+ if len(o.Output) == 0 {
+ return ""
+ }
+ for _, output := range o.Output {
+ if output.Type == ResponsesOutputTypeImageGenerationCall {
+ return output.Size
+ }
+ }
+ return ""
+}
+
type IncompleteDetails struct {
Reasoning string `json:"reasoning"`
}
@@ -283,6 +323,8 @@ type ResponsesOutput struct {
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
+ Quality string `json:"quality"`
+ Size string `json:"size"`
}
type ResponsesOutputContent struct {
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index e188889e..85938a77 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
+ if responsesResponse.HasImageGenerationCall() {
+ c.Set("image_generation_call", true)
+ c.Set("image_generation_call_quality", responsesResponse.GetQuality())
+ c.Set("image_generation_call_size", responsesResponse.GetSize())
+ }
+
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
- if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
- if streamResponse.Response.Usage.InputTokens != 0 {
- usage.PromptTokens = streamResponse.Response.Usage.InputTokens
+ if streamResponse.Response != nil {
+ if streamResponse.Response.Usage != nil {
+ if streamResponse.Response.Usage.InputTokens != 0 {
+ usage.PromptTokens = streamResponse.Response.Usage.InputTokens
+ }
+ if streamResponse.Response.Usage.OutputTokens != 0 {
+ usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
+ }
+ if streamResponse.Response.Usage.TotalTokens != 0 {
+ usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
+ }
+ if streamResponse.Response.Usage.InputTokensDetails != nil {
+ usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
+ }
}
- if streamResponse.Response.Usage.OutputTokens != 0 {
- usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
- }
- if streamResponse.Response.Usage.TotalTokens != 0 {
- usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
- }
- if streamResponse.Response.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
+ if streamResponse.Response.HasImageGenerationCall() {
+ c.Set("image_generation_call", true)
+ c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
+ c.Set("image_generation_call_size", streamResponse.Response.GetSize())
}
}
case "response.output_text.delta":
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 01ab1fff..c931fe2a 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
fileSearchTool.CallCount, dFileSearchQuota.String())
}
}
+ var dImageGenerationCallQuota decimal.Decimal
+ var imageGenerationCallPrice float64
+ if ctx.GetBool("image_generation_call") {
+ imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
+ dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
+ extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
+ }
var quotaCalculateDecimal decimal.Decimal
@@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
+ // 添加 image generation call 计费
+ quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
+ if !dImageGenerationCallQuota.IsZero() {
+ other["image_generation_call"] = true
+ other["image_generation_call_price"] = imageGenerationCallPrice
+ }
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go
index 549a1862..5b89d6fe 100644
--- a/setting/operation_setting/tools.go
+++ b/setting/operation_setting/tools.go
@@ -10,6 +10,18 @@ const (
FileSearchPrice = 2.5
)
+const (
+ GPTImage1Low1024x1024 = 0.011
+ GPTImage1Low1024x1536 = 0.016
+ GPTImage1Low1536x1024 = 0.016
+ GPTImage1Medium1024x1024 = 0.042
+ GPTImage1Medium1024x1536 = 0.063
+ GPTImage1Medium1536x1024 = 0.063
+ GPTImage1High1024x1024 = 0.167
+ GPTImage1High1024x1536 = 0.25
+ GPTImage1High1536x1024 = 0.25
+)
+
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
@@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
+
+func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
+ prices := map[string]map[string]float64{
+ "low": {
+ "1024x1024": GPTImage1Low1024x1024,
+ "1024x1536": GPTImage1Low1024x1536,
+ "1536x1024": GPTImage1Low1536x1024,
+ },
+ "medium": {
+ "1024x1024": GPTImage1Medium1024x1024,
+ "1024x1536": GPTImage1Medium1024x1536,
+ "1536x1024": GPTImage1Medium1536x1024,
+ },
+ "high": {
+ "1024x1024": GPTImage1High1024x1024,
+ "1024x1536": GPTImage1High1024x1536,
+ "1536x1024": GPTImage1High1536x1024,
+ },
+ }
+
+ if qualityMap, exists := prices[quality]; exists {
+ if price, exists := qualityMap[size]; exists {
+ return price
+ }
+ }
+
+ return GPTImage1High1024x1024
+}
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index 65332701..c331d7fe 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
+ imageGenerationCall = false,
+ imageGenerationCallPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
- (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
+ (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
+ (imageGenerationCall * imageGenerationCallPrice * groupRatio);
return (
<>
@@ -1131,7 +1134,13 @@ export function renderModelPrice(
})}
)}
-
+ {imageGenerationCall && imageGenerationCallPrice > 0 && (
+
+ {i18next.t('图片生成调用:${{price}} / 1次', {
+ price: imageGenerationCallPrice,
+ })}
+
+ )}
{(() => {
// 构建输入部分描述
@@ -1211,6 +1220,16 @@ export function renderModelPrice(
},
)
: '',
+ imageGenerationCall && imageGenerationCallPrice > 0
+ ? i18next.t(
+ ' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
+ {
+ price: imageGenerationCallPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ },
+ )
+ : '',
].join('');
return i18next.t(
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 81f3f539..d434e733 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
+ other?.image_generation_call || false,
+ other?.image_generation_call_price || 0,
);
}
expandDataLocal.push({
From b9befccd9e6ff41ecb47a4a77a8b59ba9a7b4556 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 13:02:15 +0800
Subject: [PATCH 27/89] fix: imageGenerationCall involves billing
---
web/src/helpers/render.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index c331d7fe..c19e2849 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1072,7 +1072,7 @@ export function renderModelPrice(
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
- (imageGenerationCall * imageGenerationCallPrice * groupRatio);
+ (imageGenerationCallPrice * groupRatio);
return (
<>
From 97755c2e94c3907237f7c331ce421b9a6857b242 Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Tue, 16 Sep 2025 14:30:12 +0800
Subject: [PATCH 28/89] fix: VolcEngine doubao-seedream-4-0-250828
---
controller/channel-test.go | 39 +++++++++++++++++++++++++++
relay/channel/volcengine/adaptor.go | 2 ++
relay/channel/volcengine/constants.go | 1 +
3 files changed, 42 insertions(+)
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 5a668c48..9ea6eed7 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath = "/v1/embeddings" // 修改请求路径
}
+ // VolcEngine 图像生成模型
+ if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
+ requestPath = "/v1/images/generations"
+ }
+
c.Request = &http.Request{
Method: "POST",
URL: &url.URL{Path: requestPath}, // 使用动态路径
@@ -109,6 +114,21 @@ 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{
@@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
+ if c.Request.URL.Path == "/v1/images/generations" {
+ relayFormat = types.RelayFormatOpenAIImage
+ }
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
@@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
// 调用专门用于 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
+ }
+ }
+ imageRequest := dto.ImageRequest{
+ Prompt: prompt,
+ Model: request.Model,
+ N: uint(request.N),
+ Size: request.Size,
+ }
+ // 调用专门用于图像生成的转换函数
+ convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat),保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go
index 0af019da..eb88412a 100644
--- a/relay/channel/volcengine/adaptor.go
+++ b/relay/channel/volcengine/adaptor.go
@@ -41,6 +41,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode {
+ case constant.RelayModeImagesGenerations:
+ return request, nil
case constant.RelayModeImagesEdits:
var requestBody bytes.Buffer
diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go
index 30cc902e..fca10e7c 100644
--- a/relay/channel/volcengine/constants.go
+++ b/relay/channel/volcengine/constants.go
@@ -8,6 +8,7 @@ var ModelList = []string{
"Doubao-lite-32k",
"Doubao-lite-4k",
"Doubao-embedding",
+ "doubao-seedream-4-0-250828",
}
var ChannelName = "volcengine"
From 9fa9cf38c28bdc12a3ec408ba120c57c8f874cd6 Mon Sep 17 00:00:00 2001
From: Xyfacai
Date: Tue, 16 Sep 2025 16:28:27 +0800
Subject: [PATCH 29/89] =?UTF-8?q?feat:=20jimeng=20kling=20=E6=94=AF?=
=?UTF-8?q?=E6=8C=81new=20api=20=E5=B5=8C=E5=A5=97=E4=B8=AD=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
controller/channel.go | 14 +++++++---
relay/channel/task/jimeng/adaptor.go | 38 ++++++++++++++++++++--------
relay/channel/task/kling/adaptor.go | 19 +++++++++++++-
3 files changed, 57 insertions(+), 14 deletions(-)
diff --git a/controller/channel.go b/controller/channel.go
index 403eb04c..17154ab0 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -501,9 +501,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
type AddChannelRequest struct {
- Mode string `json:"mode"`
- MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
- Channel *model.Channel `json:"channel"`
+ Mode string `json:"mode"`
+ MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
+ BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
+ Channel *model.Channel `json:"channel"`
}
func getVertexArrayKeys(keys string) ([]string, error) {
@@ -616,6 +617,13 @@ func AddChannel(c *gin.Context) {
}
localChannel := addChannelRequest.Channel
localChannel.Key = key
+ if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
+ keyPrefix := localChannel.Key
+ if len(localChannel.Key) > 8 {
+ keyPrefix = localChannel.Key[:8]
+ }
+ localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
+ }
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go
index 2bc45c54..95f3cb26 100644
--- a/relay/channel/task/jimeng/adaptor.go
+++ b/relay/channel/task/jimeng/adaptor.go
@@ -93,6 +93,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
+ if isNewAPIRelay(info.ApiKey) {
+ return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
+ }
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
@@ -100,7 +103,12 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
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")
- return a.signRequest(req, a.accessKey, a.secretKey)
+ if isNewAPIRelay(info.ApiKey) {
+ req.Header.Set("Authorization", "Bearer "+info.ApiKey)
+ } else {
+ return a.signRequest(req, a.accessKey, a.secretKey)
+ }
+ return nil
}
// BuildRequestBody converts request into Jimeng specific format.
@@ -160,6 +168,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
+ if isNewAPIRelay(key) {
+ uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL)
+ }
payload := map[string]string{
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
"task_id": taskID,
@@ -177,17 +188,20 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
- keyParts := strings.Split(key, "|")
- if len(keyParts) != 2 {
- return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
- }
- accessKey := strings.TrimSpace(keyParts[0])
- secretKey := strings.TrimSpace(keyParts[1])
+ if isNewAPIRelay(key) {
+ req.Header.Set("Authorization", "Bearer "+key)
+ } else {
+ keyParts := strings.Split(key, "|")
+ if len(keyParts) != 2 {
+ return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
+ }
+ accessKey := strings.TrimSpace(keyParts[0])
+ secretKey := strings.TrimSpace(keyParts[1])
- if err := a.signRequest(req, accessKey, secretKey); err != nil {
- return nil, errors.Wrap(err, "sign request failed")
+ if err := a.signRequest(req, accessKey, secretKey); err != nil {
+ return nil, errors.Wrap(err, "sign request failed")
+ }
}
-
return service.GetHttpClient().Do(req)
}
@@ -362,3 +376,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
taskResult.Url = resTask.Data.VideoUrl
return &taskResult, nil
}
+
+func isNewAPIRelay(apiKey string) bool {
+ return strings.HasPrefix(apiKey, "sk-")
+}
diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go
index 13f2af97..fec3396a 100644
--- a/relay/channel/task/kling/adaptor.go
+++ b/relay/channel/task/kling/adaptor.go
@@ -117,6 +117,11 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
+
+ if isNewAPIRelay(info.ApiKey) {
+ return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
+ }
+
return fmt.Sprintf("%s%s", a.baseURL, path), nil
}
@@ -199,6 +204,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
+ if isNewAPIRelay(key) {
+ url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
+ }
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -304,8 +312,13 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
//}
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
-
+ if isNewAPIRelay(apiKey) {
+ return apiKey, nil // new api relay
+ }
keyParts := strings.Split(apiKey, "|")
+ if len(keyParts) != 2 {
+ return "", errors.New("invalid api_key, required format is accessKey|secretKey")
+ }
accessKey := strings.TrimSpace(keyParts[0])
if len(keyParts) == 1 {
return accessKey, nil
@@ -352,3 +365,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
}
return taskInfo, nil
}
+
+func isNewAPIRelay(apiKey string) bool {
+ return strings.HasPrefix(apiKey, "sk-")
+}
From a2b2f1ce272f0eaed2017546248ea788f0dcae5c Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Tue, 16 Sep 2025 16:55:35 +0800
Subject: [PATCH 30/89] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Align=20se?=
=?UTF-8?q?tup=20API=20errors=20to=20HTTP=20200=20with=20{success:false,?=
=?UTF-8?q?=20message}?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.
Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
predictable errors in POST /api/setup, including:
- already initialized
- invalid payload
- username too long
- password mismatch
- password too short
- password hashing failure
- root user creation failure
- option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
- setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
(no Axios response.data parsing required).
Why
- Keep API behavior consistent across endpoints so the UI can rely on the
success flag and message in the normal .then() flow instead of falling
into Axios 4xx errors that only show a generic "400".
Impact
- UI now displays specific server messages during initialization without
frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
should inspect the JSON body (success/message) instead.
No changes to the happy path; initialization success responses are unchanged.
---
controller/setup.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/controller/setup.go b/controller/setup.go
index 44a7b3a7..3ae255e9 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
From 6c7d28af0b692a6a2c5e9c3b34dd3f45170451b0 Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Tue, 16 Sep 2025 17:21:22 +0800
Subject: [PATCH 31/89] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20Align=20se?=
=?UTF-8?q?tup=20API=20errors=20to=20HTTP=20200=20with=20{success:false,?=
=?UTF-8?q?=20message}?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.
Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
predictable errors in POST /api/setup, including:
- already initialized
- invalid payload
- username too long
- password mismatch
- password too short
- password hashing failure
- root user creation failure
- option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
- setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
(no Axios response.data parsing required).
Why
- Keep API behavior consistent across endpoints so the UI can rely on the
success flag and message in the normal .then() flow instead of falling
into Axios 4xx errors that only show a generic "400".
Impact
- UI now displays specific server messages during initialization without
frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
should inspect the JSON body (success/message) instead.
No changes to the happy path; initialization success responses are unchanged.
---
controller/setup.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/controller/setup.go b/controller/setup.go
index 44a7b3a7..3ae255e9 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
- c.JSON(400, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
- c.JSON(500, gin.H{
+ c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})
From fd8271b94a7dc74fb3a453e4d20e152c92dbe519 Mon Sep 17 00:00:00 2001
From: RixAPI
Date: Tue, 16 Sep 2025 20:03:10 +0800
Subject: [PATCH 32/89] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
增加并发支持
---
web/src/hooks/channels/useChannelsData.jsx | 363 +++++++++++----------
1 file changed, 198 insertions(+), 165 deletions(-)
diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx
index 65460a06..7d09d4df 100644
--- a/web/src/hooks/channels/useChannelsData.jsx
+++ b/web/src/hooks/channels/useChannelsData.jsx
@@ -25,13 +25,9 @@ import {
showInfo,
showSuccess,
loadChannelModels,
- copy,
+ copy
} from '../../helpers';
-import {
- CHANNEL_OPTIONS,
- ITEMS_PER_PAGE,
- MODEL_TABLE_PAGE_SIZE,
-} from '../../constants';
+import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
import { useIsMobile } from '../common/useIsMobile';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { Modal } from '@douyinfe/semi-ui';
@@ -68,7 +64,7 @@ export const useChannelsData = () => {
// Status filter
const [statusFilter, setStatusFilter] = useState(
- localStorage.getItem('channel-status-filter') || 'all',
+ localStorage.getItem('channel-status-filter') || 'all'
);
// Type tabs states
@@ -83,9 +79,10 @@ export const useChannelsData = () => {
const [testingModels, setTestingModels] = useState(new Set());
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
- const [testQueue, setTestQueue] = useState([]);
- const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
+
+ // 使用 ref 来避免闭包问题,类似旧版实现
+ const shouldStopBatchTestingRef = useRef(false);
// Multi-key management states
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
@@ -119,12 +116,9 @@ export const useChannelsData = () => {
// Initialize from localStorage
useEffect(() => {
const localIdSort = localStorage.getItem('id-sort') === 'true';
- const localPageSize =
- parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
- const localEnableTagMode =
- localStorage.getItem('enable-tag-mode') === 'true';
- const localEnableBatchDelete =
- localStorage.getItem('enable-batch-delete') === 'true';
+ const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+ const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
+ const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
setIdSort(localIdSort);
setPageSize(localPageSize);
@@ -182,10 +176,7 @@ export const useChannelsData = () => {
// Save column preferences
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
- localStorage.setItem(
- 'channels-table-columns',
- JSON.stringify(visibleColumns),
- );
+ localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
@@ -299,21 +290,14 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
- await searchChannels(
- enableTagMode,
- typeKey,
- statusF,
- page,
- pageSize,
- idSort,
- );
+ await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
setLoading(false);
return;
}
const reqId = ++requestCounter.current;
setLoading(true);
- const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
+ const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
@@ -327,10 +311,7 @@ export const useChannelsData = () => {
if (success) {
const { items, total, type_counts } = data;
if (type_counts) {
- const sumAll = Object.values(type_counts).reduce(
- (acc, v) => acc + v,
- 0,
- );
+ const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
}
setChannelFormat(items, enableTagMode);
@@ -354,18 +335,11 @@ export const useChannelsData = () => {
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
- await loadChannels(
- page,
- pageSz,
- sortFlag,
- enableTagMode,
- typeKey,
- statusF,
- );
+ await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
return;
}
- const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
+ const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
@@ -373,10 +347,7 @@ export const useChannelsData = () => {
const { success, message, data } = res.data;
if (success) {
const { items = [], total = 0, type_counts = {} } = data;
- const sumAll = Object.values(type_counts).reduce(
- (acc, v) => acc + v,
- 0,
- );
+ const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setChannelCount(total);
@@ -395,14 +366,7 @@ export const useChannelsData = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(page, pageSize, idSort, enableTagMode);
} else {
- await searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- page,
- pageSize,
- idSort,
- );
+ await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
@@ -488,16 +452,9 @@ export const useChannelsData = () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page);
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
- loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
+ loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
} else {
- searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- page,
- pageSize,
- idSort,
- );
+ searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
@@ -513,14 +470,7 @@ export const useChannelsData = () => {
showError(reason);
});
} else {
- searchChannels(
- enableTagMode,
- activeTypeKey,
- statusFilter,
- 1,
- size,
- idSort,
- );
+ searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
}
};
@@ -551,10 +501,7 @@ export const useChannelsData = () => {
showError(res?.data?.message || t('渠道复制失败'));
}
} catch (error) {
- showError(
- t('渠道复制失败: ') +
- (error?.response?.data?.message || error?.message || error),
- );
+ showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
}
};
@@ -593,11 +540,7 @@ export const useChannelsData = () => {
data.priority = parseInt(data.priority);
break;
case 'weight':
- if (
- data.weight === undefined ||
- data.weight < 0 ||
- data.weight === ''
- ) {
+ if (data.weight === undefined || data.weight < 0 || data.weight === '') {
showInfo('权重必须是非负整数!');
return;
}
@@ -740,136 +683,226 @@ export const useChannelsData = () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
if (success) {
- showSuccess(
- t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
- .replace('${success}', data.success)
- .replace('${fails}', data.fails),
- );
+ showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
await refresh();
} else {
showError(message);
}
};
- // Test channel
+ // Test channel - 单个模型测试,参考旧版实现
const testChannel = async (record, model) => {
- setTestQueue((prev) => [...prev, { channel: record, model }]);
- if (!isProcessingQueue) {
- setIsProcessingQueue(true);
+ const testKey = `${record.id}-${model}`;
+
+ // 检查是否应该停止批量测试
+ if (shouldStopBatchTestingRef.current && isBatchTesting) {
+ return Promise.resolve();
}
- };
- // Process test queue
- const processTestQueue = async () => {
- if (!isProcessingQueue || testQueue.length === 0) return;
-
- const { channel, model, indexInFiltered } = testQueue[0];
-
- if (currentTestChannel && currentTestChannel.id === channel.id) {
- let pageNo;
- if (indexInFiltered !== undefined) {
- pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
- } else {
- const filteredModelsList = currentTestChannel.models
- .split(',')
- .filter((m) =>
- m.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
- );
- const modelIdx = filteredModelsList.indexOf(model);
- pageNo =
- modelIdx !== -1
- ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1
- : 1;
- }
- setModelTablePage(pageNo);
- }
+ // 添加到正在测试的模型集合
+ setTestingModels(prev => new Set([...prev, model]));
try {
- setTestingModels((prev) => new Set([...prev, model]));
- const res = await API.get(
- `/api/channel/test/${channel.id}?model=${model}`,
- );
+ const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
+
+ // 检查是否在请求期间被停止
+ if (shouldStopBatchTestingRef.current && isBatchTesting) {
+ return Promise.resolve();
+ }
+
const { success, message, time } = res.data;
- setModelTestResults((prev) => ({
+ // 更新测试结果
+ setModelTestResults(prev => ({
...prev,
- [`${channel.id}-${model}`]: { success, time },
+ [testKey]: {
+ success,
+ message,
+ time: time || 0,
+ timestamp: Date.now()
+ }
}));
if (success) {
- updateChannelProperty(channel.id, (ch) => {
- ch.response_time = time * 1000;
- ch.test_time = Date.now() / 1000;
+ // 更新渠道响应时间
+ updateChannelProperty(record.id, (channel) => {
+ channel.response_time = time * 1000;
+ channel.test_time = Date.now() / 1000;
});
- if (!model) {
+
+ if (!model || model === '') {
showInfo(
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
- .replace('${name}', channel.name)
+ .replace('${name}', record.name)
+ .replace('${time.toFixed(2)}', time.toFixed(2)),
+ );
+ } else {
+ showInfo(
+ t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
+ .replace('${name}', record.name)
+ .replace('${model}', model)
.replace('${time.toFixed(2)}', time.toFixed(2)),
);
}
} else {
- showError(message);
+ showError(`${t('模型')} ${model}: ${message}`);
}
} catch (error) {
- showError(error.message);
+ // 处理网络错误
+ const testKey = `${record.id}-${model}`;
+ setModelTestResults(prev => ({
+ ...prev,
+ [testKey]: {
+ success: false,
+ message: error.message || t('网络错误'),
+ time: 0,
+ timestamp: Date.now()
+ }
+ }));
+ showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
} finally {
- setTestingModels((prev) => {
+ // 从正在测试的模型集合中移除
+ setTestingModels(prev => {
const newSet = new Set(prev);
newSet.delete(model);
return newSet;
});
}
-
- setTestQueue((prev) => prev.slice(1));
};
- // Monitor queue changes
- useEffect(() => {
- if (testQueue.length > 0 && isProcessingQueue) {
- processTestQueue();
- } else if (testQueue.length === 0 && isProcessingQueue) {
- setIsProcessingQueue(false);
- setIsBatchTesting(false);
- }
- }, [testQueue, isProcessingQueue]);
-
- // Batch test models
+ // 批量测试单个渠道的所有模型,参考旧版实现
const batchTestModels = async () => {
- if (!currentTestChannel) return;
+ if (!currentTestChannel || !currentTestChannel.models) {
+ showError(t('渠道模型信息不完整'));
+ return;
+ }
+
+ const models = currentTestChannel.models.split(',').filter(model =>
+ model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
+ );
+
+ if (models.length === 0) {
+ showError(t('没有找到匹配的模型'));
+ return;
+ }
setIsBatchTesting(true);
- setModelTablePage(1);
+ shouldStopBatchTestingRef.current = false; // 重置停止标志
- const filteredModels = currentTestChannel.models
- .split(',')
- .filter((model) =>
- model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
- );
+ // 清空该渠道之前的测试结果
+ setModelTestResults(prev => {
+ const newResults = { ...prev };
+ models.forEach(model => {
+ const testKey = `${currentTestChannel.id}-${model}`;
+ delete newResults[testKey];
+ });
+ return newResults;
+ });
- setTestQueue(
- filteredModels.map((model, idx) => ({
- channel: currentTestChannel,
- model,
- indexInFiltered: idx,
- })),
- );
- setIsProcessingQueue(true);
+ try {
+ showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
+
+ // 提高并发数量以加快测试速度,参考旧版的并发限制
+ const concurrencyLimit = 5;
+ const results = [];
+
+ for (let i = 0; i < models.length; i += concurrencyLimit) {
+ // 检查是否应该停止
+ if (shouldStopBatchTestingRef.current) {
+ showInfo(t('批量测试已停止'));
+ break;
+ }
+
+ const batch = models.slice(i, i + concurrencyLimit);
+ showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
+ .replace('${current}', i + 1)
+ .replace('${end}', Math.min(i + concurrencyLimit, models.length))
+ .replace('${total}', models.length)
+ );
+
+ const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
+ const batchResults = await Promise.allSettled(batchPromises);
+ results.push(...batchResults);
+
+ // 再次检查是否应该停止
+ if (shouldStopBatchTestingRef.current) {
+ showInfo(t('批量测试已停止'));
+ break;
+ }
+
+ // 短暂延迟避免过于频繁的请求
+ if (i + concurrencyLimit < models.length) {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ }
+
+ if (!shouldStopBatchTestingRef.current) {
+ // 等待一小段时间确保所有结果都已更新
+ await new Promise(resolve => setTimeout(resolve, 300));
+
+ // 使用当前状态重新计算结果统计
+ setModelTestResults(currentResults => {
+ let successCount = 0;
+ let failCount = 0;
+
+ models.forEach(model => {
+ const testKey = `${currentTestChannel.id}-${model}`;
+ const result = currentResults[testKey];
+ if (result && result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ }
+ });
+
+ // 显示完成消息
+ setTimeout(() => {
+ showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
+ .replace('${success}', successCount)
+ .replace('${fail}', failCount)
+ .replace('${total}', models.length)
+ );
+ }, 100);
+
+ return currentResults; // 不修改状态,只是为了获取最新值
+ });
+ }
+ } catch (error) {
+ showError(t('批量测试过程中发生错误: ') + error.message);
+ } finally {
+ setIsBatchTesting(false);
+ }
+ };
+
+ // 停止批量测试
+ const stopBatchTesting = () => {
+ shouldStopBatchTestingRef.current = true;
+ setIsBatchTesting(false);
+ setTestingModels(new Set());
+ showInfo(t('已停止批量测试'));
+ };
+
+ // 清空测试结果
+ const clearTestResults = () => {
+ setModelTestResults({});
+ showInfo(t('已清空测试结果'));
};
// Handle close modal
const handleCloseModal = () => {
+ // 如果正在批量测试,先停止测试
if (isBatchTesting) {
- setTestQueue([]);
- setIsProcessingQueue(false);
- setIsBatchTesting(false);
- showSuccess(t('已停止测试'));
- } else {
- setShowModelTestModal(false);
- setModelSearchKeyword('');
- setSelectedModelKeys([]);
- setModelTablePage(1);
+ shouldStopBatchTestingRef.current = true;
+ showInfo(t('关闭弹窗,已停止批量测试'));
}
+
+ setShowModelTestModal(false);
+ setModelSearchKeyword('');
+ setIsBatchTesting(false);
+ setTestingModels(new Set());
+ setSelectedModelKeys([]);
+ setModelTablePage(1);
+ // 可选择性保留测试结果,这里不清空以便用户查看
};
// Type counts
@@ -1012,4 +1045,4 @@ export const useChannelsData = () => {
setCompactMode,
setActivePage,
};
-};
+};
\ No newline at end of file
From cc9a559b75605c4df7090f537dfc4d15ba947743 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Tue, 16 Sep 2025 22:40:40 +0800
Subject: [PATCH 33/89] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=92=8Cip=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
service/download.go | 4 +-
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
setting/system_setting/fetch_setting.go | 14 ++-
web/src/components/settings/SystemSetting.jsx | 114 +++++++++++++-----
5 files changed, 99 insertions(+), 37 deletions(-)
diff --git a/service/download.go b/service/download.go
index 2f30870d..43b6fe7d 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index f9d7b669..1e9e8947 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/service/webhook.go b/service/webhook.go
index 1f159eb4..5d9ce400 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 6e47c3f0..5277e103 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -5,16 +5,20 @@ import "one-api/setting/config"
type FetchSetting struct {
EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
AllowPrivateIp bool `json:"allow_private_ip"`
- WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com
- WhitelistIps []string `json:"whitelist_ips"` // CIDR format
- AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
+ IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
+ DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
+ IpList []string `json:"ip_list"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
}
var defaultFetchSetting = FetchSetting{
EnableSSRFProtection: true, // 默认开启SSRF防护
AllowPrivateIp: false,
- WhitelistDomains: []string{},
- WhitelistIps: []string{},
+ DomainFilterMode: true,
+ IpFilterMode: true,
+ DomainList: []string{},
+ IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
}
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 71dfaac8..ebe4084b 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -29,6 +29,7 @@ import {
TagInput,
Spin,
Card,
+ Radio,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -91,8 +92,10 @@ const SystemSetting = () => {
// SSRF防护配置
'fetch_setting.enable_ssrf_protection': true,
'fetch_setting.allow_private_ip': '',
- 'fetch_setting.whitelist_domains': [],
- 'fetch_setting.whitelist_ips': [],
+ 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.domain_list': [],
+ 'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
});
@@ -105,8 +108,10 @@ const SystemSetting = () => {
useState(false);
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
const [emailToAdd, setEmailToAdd] = useState('');
- const [whitelistDomains, setWhitelistDomains] = useState([]);
- const [whitelistIps, setWhitelistIps] = useState([]);
+ const [domainFilterMode, setDomainFilterMode] = useState(true);
+ const [ipFilterMode, setIpFilterMode] = useState(true);
+ const [domainList, setDomainList] = useState([]);
+ const [ipList, setIpList] = useState([]);
const [allowedPorts, setAllowedPorts] = useState([]);
const getOptions = async () => {
@@ -125,22 +130,24 @@ const SystemSetting = () => {
break;
case 'fetch_setting.allow_private_ip':
case 'fetch_setting.enable_ssrf_protection':
+ case 'fetch_setting.domain_filter_mode':
+ case 'fetch_setting.ip_filter_mode':
item.value = toBoolean(item.value);
break;
- case 'fetch_setting.whitelist_domains':
+ case 'fetch_setting.domain_list':
try {
const domains = item.value ? JSON.parse(item.value) : [];
- setWhitelistDomains(Array.isArray(domains) ? domains : []);
+ setDomainList(Array.isArray(domains) ? domains : []);
} catch (e) {
- setWhitelistDomains([]);
+ setDomainList([]);
}
break;
- case 'fetch_setting.whitelist_ips':
+ case 'fetch_setting.ip_list':
try {
const ips = item.value ? JSON.parse(item.value) : [];
- setWhitelistIps(Array.isArray(ips) ? ips : []);
+ setIpList(Array.isArray(ips) ? ips : []);
} catch (e) {
- setWhitelistIps([]);
+ setIpList([]);
}
break;
case 'fetch_setting.allowed_ports':
@@ -178,6 +185,13 @@ const SystemSetting = () => {
});
setInputs(newInputs);
setOriginInputs(newInputs);
+ // 同步模式布尔到本地状态
+ if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
+ setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
+ }
+ if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
+ setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']);
+ }
if (formApiRef.current) {
formApiRef.current.setValues(newInputs);
}
@@ -317,19 +331,27 @@ const SystemSetting = () => {
const submitSSRF = async () => {
const options = [];
- // 处理域名白名单
- if (Array.isArray(whitelistDomains)) {
+ // 处理域名过滤模式与列表
+ options.push({
+ key: 'fetch_setting.domain_filter_mode',
+ value: domainFilterMode,
+ });
+ if (Array.isArray(domainList)) {
options.push({
- key: 'fetch_setting.whitelist_domains',
- value: JSON.stringify(whitelistDomains),
+ key: 'fetch_setting.domain_list',
+ value: JSON.stringify(domainList),
});
}
- // 处理IP白名单
- if (Array.isArray(whitelistIps)) {
+ // 处理IP过滤模式与列表
+ options.push({
+ key: 'fetch_setting.ip_filter_mode',
+ value: ipFilterMode,
+ });
+ if (Array.isArray(ipList)) {
options.push({
- key: 'fetch_setting.whitelist_ips',
- value: JSON.stringify(whitelistIps),
+ key: 'fetch_setting.ip_list',
+ value: JSON.stringify(ipList),
});
}
@@ -702,25 +724,43 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
- {t('域名白名单')}
+
+ {t(domainFilterMode ? '域名白名单' : '域名黑名单')}
+
{t('支持通配符格式,如:example.com, *.api.example.com')}
+ {
+ const isWhitelist = val === 'whitelist';
+ setDomainFilterMode(isWhitelist);
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.domain_filter_mode': isWhitelist,
+ }));
+ }}
+ style={{ marginBottom: 8 }}
+ >
+ {t('白名单')}
+ {t('黑名单')}
+
{
- setWhitelistDomains(value);
+ setDomainList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
- 'fetch_setting.whitelist_domains': value
+ 'fetch_setting.domain_list': value
}));
}}
placeholder={t('输入域名后回车,如:example.com')}
style={{ width: '100%' }}
/>
- {t('域名白名单详细说明')}
+ {t('域名过滤详细说明')}
@@ -730,25 +770,43 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
- {t('IP白名单')}
+
+ {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
+
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
+ {
+ const isWhitelist = val === 'whitelist';
+ setIpFilterMode(isWhitelist);
+ setInputs(prev => ({
+ ...prev,
+ 'fetch_setting.ip_filter_mode': isWhitelist,
+ }));
+ }}
+ style={{ marginBottom: 8 }}
+ >
+ {t('白名单')}
+ {t('黑名单')}
+
{
- setWhitelistIps(value);
+ setIpList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
- 'fetch_setting.whitelist_ips': value
+ 'fetch_setting.ip_list': value
}));
}}
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
style={{ width: '100%' }}
/>
- {t('IP白名单详细说明')}
+ {t('IP过滤详细说明')}
From b46bffde03cc4a686de19a13d8036af112be5965 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 15:41:21 +0800
Subject: [PATCH 34/89] =?UTF-8?q?feat:=20ssrf=E6=94=AF=E6=8C=81=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=92=8Cip=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95?=
=?UTF-8?q?=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/ssrf_protection.go | 199 ++++++++++++++------------------------
service/download.go | 4 +-
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
4 files changed, 74 insertions(+), 133 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index b0988d90..52b83952 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -11,16 +11,20 @@ import (
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
AllowPrivateIp bool
- WhitelistDomains []string // domain format, e.g. example.com, *.example.com
- WhitelistIps []string // CIDR format
+ DomainFilterMode bool // true: 白名单, false: 黑名单
+ DomainList []string // domain format, e.g. example.com, *.example.com
+ IpFilterMode bool // true: 白名单, false: 黑名单
+ IpList []string // CIDR or single IP
AllowedPorts []int // 允许的端口范围
}
// DefaultSSRFProtection 默认SSRF防护配置
var DefaultSSRFProtection = &SSRFProtection{
AllowPrivateIp: false,
- WhitelistDomains: []string{},
- WhitelistIps: []string{},
+ DomainFilterMode: true,
+ DomainList: []string{},
+ IpFilterMode: true,
+ IpList: []string{},
AllowedPorts: []int{},
}
@@ -138,44 +142,25 @@ func (p *SSRFProtection) isAllowedPort(port int) bool {
return false
}
-// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许
-func isAllowedPortFromRanges(port int, portRanges []string) bool {
- if len(portRanges) == 0 {
- return true // 如果没有配置端口限制,则允许所有端口
- }
-
- allowedPorts, err := parsePortRanges(portRanges)
- if err != nil {
- // 如果解析失败,为安全起见拒绝访问
- return false
- }
-
- for _, allowedPort := range allowedPorts {
- if port == allowedPort {
- return true
- }
- }
- return false
-}
-
// isDomainWhitelisted 检查域名是否在白名单中
-func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
- if len(p.WhitelistDomains) == 0 {
+func isDomainListed(domain string, list []string) bool {
+ if len(list) == 0 {
return false
}
domain = strings.ToLower(domain)
- for _, whitelistDomain := range p.WhitelistDomains {
- whitelistDomain = strings.ToLower(whitelistDomain)
-
+ for _, item := range list {
+ item = strings.ToLower(strings.TrimSpace(item))
+ if item == "" {
+ continue
+ }
// 精确匹配
- if domain == whitelistDomain {
+ if domain == item {
return true
}
-
// 通配符匹配 (*.example.com)
- if strings.HasPrefix(whitelistDomain, "*.") {
- suffix := strings.TrimPrefix(whitelistDomain, "*.")
+ if strings.HasPrefix(item, "*.") {
+ suffix := strings.TrimPrefix(item, "*.")
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
return true
}
@@ -184,13 +169,23 @@ func (p *SSRFProtection) isDomainWhitelisted(domain string) bool {
return false
}
+func (p *SSRFProtection) isDomainAllowed(domain string) bool {
+ listed := isDomainListed(domain, p.DomainList)
+ if p.DomainFilterMode { // 白名单
+ return listed
+ }
+ // 黑名单
+ return !listed
+}
+
// isIPWhitelisted 检查IP是否在白名单中
-func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
- if len(p.WhitelistIps) == 0 {
+
+func isIPListed(ip net.IP, list []string) bool {
+ if len(list) == 0 {
return false
}
- for _, whitelistCIDR := range p.WhitelistIps {
+ for _, whitelistCIDR := range list {
_, network, err := net.ParseCIDR(whitelistCIDR)
if err != nil {
// 尝试作为单个IP处理
@@ -211,22 +206,17 @@ func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool {
// IsIPAccessAllowed 检查IP是否允许访问
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
- // 如果IP在白名单中,直接允许访问(绕过私有IP检查)
- if p.isIPWhitelisted(ip) {
- return true
+ // 私有IP限制
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
+ return false
}
- // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查)
- if len(p.WhitelistIps) == 0 {
- // 检查私有IP限制
- if isPrivateIP(ip) && !p.AllowPrivateIp {
- return false
- }
- return true
+ listed := isIPListed(ip, p.IpList)
+ if p.IpFilterMode { // 白名单
+ return listed
}
-
- // 如果IP白名单不为空且IP不在白名单中,拒绝访问
- return false
+ // 黑名单
+ return !listed
}
// ValidateURL 验证URL是否安全
@@ -264,28 +254,44 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return fmt.Errorf("port %d is not allowed", port)
}
- // 检查域名白名单
- if p.isDomainWhitelisted(host) {
- return nil // 白名单域名直接通过
+ // 如果 host 是 IP,则跳过域名检查
+ if ip := net.ParseIP(host); ip != nil {
+ if !p.IsIPAccessAllowed(ip) {
+ if isPrivateIP(ip) {
+ return fmt.Errorf("private IP address not allowed: %s", ip.String())
+ }
+ if p.IpFilterMode {
+ return fmt.Errorf("ip not in whitelist: %s", ip.String())
+ }
+ return fmt.Errorf("ip in blacklist: %s", ip.String())
+ }
+ return nil
}
- // DNS解析获取IP地址
+ // 先进行域名过滤
+ if !p.isDomainAllowed(host) {
+ if p.DomainFilterMode {
+ return fmt.Errorf("domain not in whitelist: %s", host)
+ }
+ return fmt.Errorf("domain in blacklist: %s", host)
+ }
+
+ // 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
}
-
- // 检查所有解析的IP地址
for _, ip := range ips {
if !p.IsIPAccessAllowed(ip) {
- if isPrivateIP(ip) {
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
- } else {
- return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
}
+ if p.IpFilterMode {
+ return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
+ }
+ return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
}
}
-
return nil
}
@@ -295,7 +301,7 @@ func ValidateURLWithDefaults(urlStr string) error {
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
-func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
// 如果SSRF防护被禁用,直接返回成功
if !enableSSRFProtection {
return nil
@@ -309,76 +315,11 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva
protection := &SSRFProtection{
AllowPrivateIp: allowPrivateIp,
- WhitelistDomains: whitelistDomains,
- WhitelistIps: whitelistIps,
+ DomainFilterMode: domainFilterMode,
+ DomainList: domainList,
+ IpFilterMode: ipFilterMode,
+ IpList: ipList,
AllowedPorts: allowedPortInts,
}
return protection.ValidateURL(urlStr)
}
-
-// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本)
-func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error {
- // 解析URL
- u, err := url.Parse(urlStr)
- if err != nil {
- return fmt.Errorf("invalid URL format: %v", err)
- }
-
- // 只允许HTTP/HTTPS协议
- if u.Scheme != "http" && u.Scheme != "https" {
- return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
- }
-
- // 解析主机和端口
- host, portStr, err := net.SplitHostPort(u.Host)
- if err != nil {
- // 没有端口,使用默认端口
- host = u.Host
- if u.Scheme == "https" {
- portStr = "443"
- } else {
- portStr = "80"
- }
- }
-
- // 验证端口
- port, err := strconv.Atoi(portStr)
- if err != nil {
- return fmt.Errorf("invalid port: %s", portStr)
- }
-
- if !isAllowedPortFromRanges(port, allowedPorts) {
- return fmt.Errorf("port %d is not allowed", port)
- }
-
- // 创建临时的SSRFProtection来复用域名和IP检查逻辑
- protection := &SSRFProtection{
- AllowPrivateIp: allowPrivateIp,
- WhitelistDomains: whitelistDomains,
- WhitelistIps: whitelistIps,
- }
-
- // 检查域名白名单
- if protection.isDomainWhitelisted(host) {
- return nil // 白名单域名直接通过
- }
-
- // DNS解析获取IP地址
- ips, err := net.LookupIP(host)
- if err != nil {
- return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
- }
-
- // 检查所有解析的IP地址
- for _, ip := range ips {
- if !protection.IsIPAccessAllowed(ip) {
- if isPrivateIP(ip) {
- return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
- } else {
- return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String())
- }
- }
- }
-
- return nil
-}
diff --git a/service/download.go b/service/download.go
index 43b6fe7d..c07c9e1c 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index 1e9e8947..76d15903 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
diff --git a/service/webhook.go b/service/webhook.go
index 5d9ce400..b7fd13df 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
return fmt.Errorf("request reject: %v", err)
}
From f9a6e7f04f7c7b25f9004f98784ba9ae84c37299 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:29:18 +0800
Subject: [PATCH 35/89] feat: remove ValidateURLWithDefaults
---
common/ssrf_protection.go | 5 -----
1 file changed, 5 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index 52b83952..e48ca0e0 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -295,11 +295,6 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return nil
}
-// ValidateURLWithDefaults 使用默认配置验证URL
-func ValidateURLWithDefaults(urlStr string) error {
- return DefaultSSRFProtection.ValidateURL(urlStr)
-}
-
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
// 如果SSRF防护被禁用,直接返回成功
From 82163b4be77781f99a4350f1c82b4f6ed7cd7839 Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:46:04 +0800
Subject: [PATCH 36/89] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?=
=?UTF-8?q?=E5=90=8D=E5=90=AF=E7=94=A8ip=E8=BF=87=E6=BB=A4=E5=BC=80?=
=?UTF-8?q?=E5=85=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/ssrf_protection.go | 33 +++++++++++--------
service/download.go | 4 +--
service/user_notify.go | 2 +-
service/webhook.go | 2 +-
setting/system_setting/fetch_setting.go | 30 +++++++++--------
web/src/components/settings/SystemSetting.jsx | 19 +++++++++--
6 files changed, 57 insertions(+), 33 deletions(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index e48ca0e0..40d3b10b 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -10,12 +10,13 @@ import (
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
- AllowPrivateIp bool
- DomainFilterMode bool // true: 白名单, false: 黑名单
- DomainList []string // domain format, e.g. example.com, *.example.com
- IpFilterMode bool // true: 白名单, false: 黑名单
- IpList []string // CIDR or single IP
- AllowedPorts []int // 允许的端口范围
+ AllowPrivateIp bool
+ DomainFilterMode bool // true: 白名单, false: 黑名单
+ DomainList []string // domain format, e.g. example.com, *.example.com
+ IpFilterMode bool // true: 白名单, false: 黑名单
+ IpList []string // CIDR or single IP
+ AllowedPorts []int // 允许的端口范围
+ ApplyIPFilterForDomain bool // 对域名启用IP过滤
}
// DefaultSSRFProtection 默认SSRF防护配置
@@ -276,6 +277,11 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
return fmt.Errorf("domain in blacklist: %s", host)
}
+ // 若未启用对域名应用IP过滤,则到此通过
+ if !p.ApplyIPFilterForDomain {
+ return nil
+ }
+
// 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
@@ -296,7 +302,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
-func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error {
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
// 如果SSRF防护被禁用,直接返回成功
if !enableSSRFProtection {
return nil
@@ -309,12 +315,13 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva
}
protection := &SSRFProtection{
- AllowPrivateIp: allowPrivateIp,
- DomainFilterMode: domainFilterMode,
- DomainList: domainList,
- IpFilterMode: ipFilterMode,
- IpList: ipList,
- AllowedPorts: allowedPortInts,
+ AllowPrivateIp: allowPrivateIp,
+ DomainFilterMode: domainFilterMode,
+ DomainList: domainList,
+ IpFilterMode: ipFilterMode,
+ IpList: ipList,
+ AllowedPorts: allowedPortInts,
+ ApplyIPFilterForDomain: applyIPFilterForDomain,
}
return protection.ValidateURL(urlStr)
}
diff --git a/service/download.go b/service/download.go
index c07c9e1c..036c43af 100644
--- a/service/download.go
+++ b/service/download.go
@@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
@@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
} else {
// SSRF防护:验证请求URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
diff --git a/service/user_notify.go b/service/user_notify.go
index 76d15903..fba12d9d 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
} else {
// SSRF防护:验证Bark 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); err != nil {
+ 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)
}
diff --git a/service/webhook.go b/service/webhook.go
index b7fd13df..c678b863 100644
--- a/service/webhook.go
+++ b/service/webhook.go
@@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
} else {
// SSRF防护:验证Webhook URL(非Worker模式)
fetchSetting := system_setting.GetFetchSetting()
- if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil {
+ if err := common.ValidateURLWithFetchSetting(webhookURL, 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)
}
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 5277e103..3c7f1e05 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -3,23 +3,25 @@ package system_setting
import "one-api/setting/config"
type FetchSetting struct {
- EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
- AllowPrivateIp bool `json:"allow_private_ip"`
- DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
- IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
- DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
- IpList []string `json:"ip_list"` // CIDR format
- AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
+ AllowPrivateIp bool `json:"allow_private_ip"`
+ DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
+ IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
+ DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
+ IpList []string `json:"ip_list"` // CIDR format
+ AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
+ ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性)
}
var defaultFetchSetting = FetchSetting{
- EnableSSRFProtection: true, // 默认开启SSRF防护
- AllowPrivateIp: false,
- DomainFilterMode: true,
- IpFilterMode: true,
- DomainList: []string{},
- IpList: []string{},
- AllowedPorts: []string{"80", "443", "8080", "8443"},
+ EnableSSRFProtection: true, // 默认开启SSRF防护
+ AllowPrivateIp: false,
+ DomainFilterMode: true,
+ IpFilterMode: true,
+ DomainList: []string{},
+ IpList: []string{},
+ AllowedPorts: []string{"80", "443", "8080", "8443"},
+ ApplyIPFilterForDomain: false,
}
func init() {
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index ebe4084b..a1d26a4a 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -97,6 +97,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
+ 'fetch_setting.apply_ip_filter_for_domain': false,
});
const [originInputs, setOriginInputs] = useState({});
@@ -132,6 +133,7 @@ const SystemSetting = () => {
case 'fetch_setting.enable_ssrf_protection':
case 'fetch_setting.domain_filter_mode':
case 'fetch_setting.ip_filter_mode':
+ case 'fetch_setting.apply_ip_filter_for_domain':
item.value = toBoolean(item.value);
break;
case 'fetch_setting.domain_list':
@@ -724,6 +726,17 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
+
+
+ handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
+ }
+ style={{ marginBottom: 8 }}
+ >
+ {t('对域名启用 IP 过滤(实验性)')}
+
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -734,7 +747,8 @@ const SystemSetting = () => {
type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
- const isWhitelist = val === 'whitelist';
+ const selected = val && val.target ? val.target.value : val;
+ const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
@@ -780,7 +794,8 @@ const SystemSetting = () => {
type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
- const isWhitelist = val === 'whitelist';
+ const selected = val && val.target ? val.target.value : val;
+ const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
From 7af1dc42d4bd1ecca6eb705b67cba3548317b33b Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:47:59 +0800
Subject: [PATCH 37/89] fix: use u.Hostname() instead of u.Host to avoid ipv6
host parse failed
---
common/ssrf_protection.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go
index 40d3b10b..6f7d289f 100644
--- a/common/ssrf_protection.go
+++ b/common/ssrf_protection.go
@@ -237,7 +237,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error {
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
// 没有端口,使用默认端口
- host = u.Host
+ host = u.Hostname()
if u.Scheme == "https" {
portStr = "443"
} else {
From 98d5b3dbcbd3dbcc2224525a40bbe2983b9127da Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 17 Sep 2025 23:54:34 +0800
Subject: [PATCH 38/89] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?=
=?UTF-8?q?=E4=BD=99=E7=9A=84=E8=AF=B4=E6=98=8E=E6=96=87=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/settings/SystemSetting.jsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index a1d26a4a..3218cdf0 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -773,9 +773,6 @@ const SystemSetting = () => {
placeholder={t('输入域名后回车,如:example.com')}
style={{ width: '100%' }}
/>
-
- {t('域名过滤详细说明')}
-
@@ -820,9 +817,6 @@ const SystemSetting = () => {
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
style={{ width: '100%' }}
/>
-
- {t('IP过滤详细说明')}
-
From 1c13fc0e04b98495930639493816edb67d755a70 Mon Sep 17 00:00:00 2001
From: MyPrototypeWhat
Date: Thu, 18 Sep 2025 12:01:35 +0800
Subject: [PATCH 39/89] refactor: Enhance UserArea dropdown positioning with
useRef
- Added useRef to manage dropdown positioning in UserArea component.
- Wrapped Dropdown in a div with a ref to ensure correct popup container.
- Minor adjustments to maintain existing functionality and styling.
---
.../components/layout/headerbar/UserArea.jsx | 170 +++++++++---------
1 file changed, 87 insertions(+), 83 deletions(-)
diff --git a/web/src/components/layout/headerbar/UserArea.jsx b/web/src/components/layout/headerbar/UserArea.jsx
index 8ea70f47..9fc011da 100644
--- a/web/src/components/layout/headerbar/UserArea.jsx
+++ b/web/src/components/layout/headerbar/UserArea.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React from 'react';
+import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react';
@@ -39,6 +39,7 @@ const UserArea = ({
navigate,
t,
}) => {
+ const dropdownRef = useRef(null);
if (isLoading) {
return (
- {
- navigate('/console/personal');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('个人设置')}
-
-
- {
- navigate('/console/token');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('令牌管理')}
-
-
- {
- navigate('/console/topup');
- }}
- className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
- >
-
-
- {t('钱包管理')}
-
-
-
-
-
- {t('退出')}
-
-
-
- }
- >
-
-
+
+ {userState.user.username[0].toUpperCase()}
+
+
+
+ {userState.user.username}
+
+
+
+
+
+
);
} else {
const showRegisterButton = !isSelfUseMode;
From 0008d2e3a0e58b37e2b5d63a889520e36aa86a57 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 18 Sep 2025 13:40:52 +0800
Subject: [PATCH 40/89] feat: add experimental IP filtering for domains and
update related settings
---
setting/system_setting/fetch_setting.go | 4 ++--
web/src/components/settings/SystemSetting.jsx | 6 +++---
web/src/i18n/locales/en.json | 8 ++++++--
web/src/i18n/locales/zh.json | 3 ++-
4 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go
index 3c7f1e05..c41b930a 100644
--- a/setting/system_setting/fetch_setting.go
+++ b/setting/system_setting/fetch_setting.go
@@ -16,8 +16,8 @@ type FetchSetting struct {
var defaultFetchSetting = FetchSetting{
EnableSSRFProtection: true, // 默认开启SSRF防护
AllowPrivateIp: false,
- DomainFilterMode: true,
- IpFilterMode: true,
+ DomainFilterMode: false,
+ IpFilterMode: false,
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx
index 3218cdf0..f9a2c019 100644
--- a/web/src/components/settings/SystemSetting.jsx
+++ b/web/src/components/settings/SystemSetting.jsx
@@ -92,8 +92,8 @@ const SystemSetting = () => {
// SSRF防护配置
'fetch_setting.enable_ssrf_protection': true,
'fetch_setting.allow_private_ip': '',
- 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单
- 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单
+ 'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单
+ 'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
@@ -726,10 +726,10 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
-
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 6759f53e..0af06477 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2098,7 +2098,6 @@
"支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com",
"域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.",
"输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com",
- "IP白名单": "IP Whitelist",
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24",
"IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.",
"输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
@@ -2106,5 +2105,10 @@
"支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999",
"端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.",
"输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999",
- "更新SSRF防护设置": "Update SSRF Protection Settings"
+ "更新SSRF防护设置": "Update SSRF Protection Settings",
+ "对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)",
+ "域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
+ "域名黑名单": "Domain Blacklist",
+ "白名单": "Whitelist",
+ "黑名单": "Blacklist"
}
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 71777044..95fa0641 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -31,5 +31,6 @@
"支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999",
"端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。",
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
- "更新SSRF防护设置": "更新SSRF防护设置"
+ "更新SSRF防护设置": "更新SSRF防护设置",
+ "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。"
}
From c702abfd7a23bb920dd4ae5596e9a7bccf95c737 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Thu, 18 Sep 2025 13:53:58 +0800
Subject: [PATCH 41/89] CI
---
.github/workflows/docker-image-arm64.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml
index 8e4656aa..cabf3cec 100644
--- a/.github/workflows/docker-image-arm64.yml
+++ b/.github/workflows/docker-image-arm64.yml
@@ -53,4 +53,5 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
+ provenance: false
\ No newline at end of file
From 7077756241dad3b9bb17acc4d1f72a958e5073ec Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:14:25 +0800
Subject: [PATCH 42/89] fix: kimi claude code
---
relay/channel/moonshot/adaptor.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go
index e290c239..f24976bb 100644
--- a/relay/channel/moonshot/adaptor.go
+++ b/relay/channel/moonshot/adaptor.go
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- adaptor := openai.Adaptor{}
+ adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
From 06ccdcbaa419a8f85368b81e802de55b46a8938a Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:19:44 +0800
Subject: [PATCH 43/89] feat: deepseek claude endpoint
---
relay/channel/deepseek/adaptor.go | 25 +++++++++++++++----------
1 file changed, 15 insertions(+), 10 deletions(-)
diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go
index 17d732ab..292c1e4b 100644
--- a/relay/channel/deepseek/adaptor.go
+++ b/relay/channel/deepseek/adaptor.go
@@ -3,17 +3,17 @@ package deepseek
import (
"errors"
"fmt"
+ "github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
+ "one-api/relay/channel/claude"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
-
- "github.com/gin-gonic/gin"
)
type Adaptor struct {
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
- adaptor := openai.Adaptor{}
+ adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
@@ -44,14 +44,19 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fimBaseUrl := info.ChannelBaseUrl
- if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
- fimBaseUrl += "/beta"
- }
- switch info.RelayMode {
- case constant.RelayModeCompletions:
- return fmt.Sprintf("%s/completions", fimBaseUrl), nil
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil
default:
- return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
+ if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
+ fimBaseUrl += "/beta"
+ }
+ switch info.RelayMode {
+ case constant.RelayModeCompletions:
+ return fmt.Sprintf("%s/completions", fimBaseUrl), nil
+ default:
+ return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
+ }
}
}
From d4fc4f280e5addd106fa7254543759cf8f1e07e4 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Thu, 18 Sep 2025 16:32:29 +0800
Subject: [PATCH 44/89] feat: deepseek claude endpoint
---
relay/channel/deepseek/adaptor.go | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go
index 292c1e4b..962f8794 100644
--- a/relay/channel/deepseek/adaptor.go
+++ b/relay/channel/deepseek/adaptor.go
@@ -92,12 +92,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
- if info.IsStream {
- usage, err = openai.OaiStreamHandler(c, info, resp)
- } else {
- usage, err = openai.OpenaiHandler(c, info, resp)
+ switch info.RelayFormat {
+ case types.RelayFormatClaude:
+ if info.IsStream {
+ return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
+ } else {
+ return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
+ }
+ default:
+ adaptor := openai.Adaptor{}
+ return adaptor.DoResponse(c, resp, info)
}
- return
}
func (a *Adaptor) GetModelList() []string {
From 5cae6be1088021d9c3970ae9973e1aceacfad76e Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Fri, 19 Sep 2025 00:24:01 +0800
Subject: [PATCH 45/89] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20gemini-embed?=
=?UTF-8?q?ding-001?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/gemini/adaptor.go | 4 ++--
relay/helper/valid_request.go | 16 ++++++++++++++--
setting/ratio_setting/model_ratio.go | 1 +
3 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go
index 4968f78f..57542aa5 100644
--- a/relay/channel/gemini/adaptor.go
+++ b/relay/channel/gemini/adaptor.go
@@ -215,8 +215,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeGemini {
- if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
- strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
+ if strings.Contains(info.RequestURLPath, ":embedContent") ||
+ strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
return NativeGeminiEmbeddingHandler(c, resp, info)
}
if info.IsStream {
diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go
index 4d1c1f9b..f4a290ec 100644
--- a/relay/helper/valid_request.go
+++ b/relay/helper/valid_request.go
@@ -21,7 +21,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
case types.RelayFormatOpenAI:
request, err = GetAndValidateTextRequest(c, relayMode)
case types.RelayFormatGemini:
- request, err = GetAndValidateGeminiRequest(c)
+ if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
+ request, err = GetAndValidateGeminiEmbeddingRequest(c)
+ } else {
+ request, err = GetAndValidateGeminiRequest(c)
+ }
case types.RelayFormatClaude:
request, err = GetAndValidateClaudeRequest(c)
case types.RelayFormatOpenAIResponses:
@@ -288,7 +292,6 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
}
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
-
request := &dto.GeminiChatRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
@@ -304,3 +307,12 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error)
return request, nil
}
+
+func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) {
+ request := &dto.GeminiEmbeddingRequest{}
+ err := common.UnmarshalBodyReusable(c, request)
+ if err != nil {
+ return nil, err
+ }
+ return request, nil
+}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f06cd71e..9f11a3b7 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -178,6 +178,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-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
From 241b92b28edec5455851adbe802f157893cf7019 Mon Sep 17 00:00:00 2001
From: joesonshaw
Date: Fri, 19 Sep 2025 10:49:47 +0800
Subject: [PATCH 46/89] =?UTF-8?q?fix(relay-xunfei):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E8=AE=AF=E9=A3=9E=E6=B8=A0=E9=81=93=E6=97=A0=E6=B3=95=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E9=97=AE=E9=A2=98=20#1740?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将连接延迟关闭逻辑调整到协程中执行,防止在完全接收到所有数据前提前关闭
---
relay/channel/xunfei/relay-xunfei.go | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go
index 9d5c190f..9503d5d3 100644
--- a/relay/channel/xunfei/relay-xunfei.go
+++ b/relay/channel/xunfei/relay-xunfei.go
@@ -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 {
From fdc3030e4795a574733a7ba24466fbc1f0a4d357 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:20:35 +0800
Subject: [PATCH 47/89] CI
---
.github/workflows/docker-image-arm64.yml | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml
index cabf3cec..8e4656aa 100644
--- a/.github/workflows/docker-image-arm64.yml
+++ b/.github/workflows/docker-image-arm64.yml
@@ -53,5 +53,4 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- provenance: false
\ No newline at end of file
+ labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
From abfb35b53b6391ff92b9e79c1b2d7e65a2dc5a83 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:21:32 +0800
Subject: [PATCH 48/89] fix: cast option.Value to string for ratio updates
---
controller/option.go | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/controller/option.go b/controller/option.go
index 3e59c68e..7d1c676f 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -129,7 +129,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "ImageRatio":
- err = ratio_setting.UpdateImageRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -138,7 +138,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioRatio":
- err = ratio_setting.UpdateAudioRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -147,7 +147,7 @@ func UpdateOption(c *gin.Context) {
return
}
case "AudioCompletionRatio":
- err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value)
+ err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
From d77aa81f4f6145b565da24a8d87dc1851a148b7d Mon Sep 17 00:00:00 2001
From: CaIon
Date: Fri, 19 Sep 2025 14:23:08 +0800
Subject: [PATCH 49/89] feat: update labels for ratio settings to clarify model
support
---
web/src/pages/Setting/Ratio/ModelRatioSettings.jsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
index b4095126..ed982edc 100644
--- a/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
@@ -225,8 +225,8 @@ export default function ModelRatioSettings(props) {
Date: Mon, 1 Sep 2025 09:52:52 +0800
Subject: [PATCH 50/89] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?=
=?UTF-8?q?=E9=9A=90=E8=97=8F=E5=90=8E=E6=97=A0=E6=B3=95=E5=8D=B3=E6=97=B6?=
=?UTF-8?q?=E7=94=9F=E6=95=88=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../personal/cards/NotificationSettings.jsx | 9 ++++++-
web/src/hooks/common/useSidebar.js | 27 ++++++++++++++++---
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index 0b097eaf..aad612d2 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
import { StatusContext } from '../../../../context/Status';
import { UserContext } from '../../../../context/User';
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
const NotificationSettings = ({
t,
@@ -97,6 +98,9 @@ const NotificationSettings = ({
isSidebarModuleAllowed,
} = useUserPermissions();
+ // 使用useSidebar钩子获取刷新方法
+ const { refreshUserConfig } = useSidebar();
+
// 左侧边栏设置处理函数
const handleSectionChange = (sectionKey) => {
return (checked) => {
@@ -132,6 +136,9 @@ const NotificationSettings = ({
});
if (res.data.success) {
showSuccess(t('侧边栏设置保存成功'));
+
+ // 刷新useSidebar钩子中的用户配置,实现实时更新
+ await refreshUserConfig();
} else {
showError(res.data.message);
}
@@ -334,7 +341,7 @@ const NotificationSettings = ({
loading={sidebarLoading}
className='!rounded-lg'
>
- {t('保存边栏设置')}
+ {t('保存设置')}
>
) : (
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
index 5dce44f9..e964855e 100644
--- a/web/src/hooks/common/useSidebar.js
+++ b/web/src/hooks/common/useSidebar.js
@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
import { StatusContext } from '../../context/Status';
import { API } from '../../helpers';
+// 创建一个全局事件系统来同步所有useSidebar实例
+const sidebarEventTarget = new EventTarget();
+const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
+
export const useSidebar = () => {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
@@ -124,9 +128,11 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
- if (Object.keys(adminConfig).length > 0) {
- await loadUserConfig();
- }
+ // 移除adminConfig的条件限制,直接刷新用户配置
+ await loadUserConfig();
+
+ // 触发全局刷新事件,通知所有useSidebar实例更新
+ sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
};
// 加载用户配置
@@ -137,6 +143,21 @@ export const useSidebar = () => {
}
}, [adminConfig]);
+ // 监听全局刷新事件
+ useEffect(() => {
+ const handleRefresh = () => {
+ if (Object.keys(adminConfig).length > 0) {
+ loadUserConfig();
+ }
+ };
+
+ sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+
+ return () => {
+ sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+ };
+ }, [adminConfig]);
+
// 计算最终的显示配置
const finalConfig = useMemo(() => {
const result = {};
From b79fe6cff08842906d8710c4cf4a5d81bc08942f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Mon, 1 Sep 2025 10:20:15 +0800
Subject: [PATCH 51/89] =?UTF-8?q?=E4=BF=AE=E5=A4=8D"=E8=BE=B9=E6=A0=8F"?=
=?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/hooks/common/useSidebar.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
index e964855e..13d76fd8 100644
--- a/web/src/hooks/common/useSidebar.js
+++ b/web/src/hooks/common/useSidebar.js
@@ -128,8 +128,9 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
- // 移除adminConfig的条件限制,直接刷新用户配置
- await loadUserConfig();
+ if (Object.keys(adminConfig).length > 0) {
+ await loadUserConfig();
+ }
// 触发全局刷新事件,通知所有useSidebar实例更新
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
From 647ed1be834d32d6e6b12b9bc25dd8e39aebfe25 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Tue, 2 Sep 2025 18:10:08 +0800
Subject: [PATCH 52/89] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?=
=?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/auth/ModuleRoute.jsx | 200 ++++++++++++++++++++++++
1 file changed, 200 insertions(+)
create mode 100644 web/src/components/auth/ModuleRoute.jsx
diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx
new file mode 100644
index 00000000..3f208c7f
--- /dev/null
+++ b/web/src/components/auth/ModuleRoute.jsx
@@ -0,0 +1,200 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useContext } from 'react';
+import { Navigate } from 'react-router-dom';
+import { StatusContext } from '../../context/Status';
+import Loading from '../common/ui/Loading';
+import { API } from '../../helpers';
+
+/**
+ * ModuleRoute - 基于功能模块权限的路由保护组件
+ *
+ * @param {Object} props
+ * @param {React.ReactNode} props.children - 要保护的子组件
+ * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token"
+ * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden
+ * @returns {React.ReactNode}
+ */
+const ModuleRoute = ({ children, modulePath, fallback = }) => {
+ const [hasPermission, setHasPermission] = useState(null);
+ const [statusState] = useContext(StatusContext);
+
+ useEffect(() => {
+ checkModulePermission();
+ }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查
+
+ const checkModulePermission = async () => {
+ try {
+ // 检查用户是否已登录
+ const user = localStorage.getItem('user');
+ if (!user) {
+ setHasPermission(false);
+ return;
+ }
+
+ const userData = JSON.parse(user);
+ const userRole = userData.role;
+
+ // 超级管理员始终有权限
+ if (userRole >= 100) {
+ setHasPermission(true);
+ return;
+ }
+
+ // 检查模块权限
+ const permission = await checkModulePermissionAPI(modulePath);
+
+ // 如果返回null,表示status数据还未加载完成,保持loading状态
+ if (permission === null) {
+ setHasPermission(null);
+ return;
+ }
+
+ setHasPermission(permission);
+ } catch (error) {
+ console.error('检查模块权限失败:', error);
+ // 出错时采用安全优先策略,拒绝访问
+ setHasPermission(false);
+ }
+ };
+
+ const checkModulePermissionAPI = async (modulePath) => {
+ try {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if (modulePath === 'console.detail') {
+ return true;
+ }
+
+ // 从StatusContext中获取配置信息
+ // 如果status数据还未加载完成,返回null表示需要等待
+ if (!statusState?.status) {
+ return null;
+ }
+
+ const user = JSON.parse(localStorage.getItem('user'));
+ const userRole = user.role;
+
+ // 解析模块路径
+ const pathParts = modulePath.split('.');
+ if (pathParts.length < 2) {
+ return false;
+ }
+
+ // 普通用户权限检查
+ if (userRole < 10) {
+ return await isUserModuleAllowed(modulePath);
+ }
+
+ // 超级管理员权限检查 - 不受系统配置限制
+ if (userRole >= 100) {
+ return true;
+ }
+
+ // 管理员权限检查 - 受系统配置限制
+ if (userRole >= 10 && userRole < 100) {
+ // 从/api/user/self获取系统权限配置
+ try {
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_config) {
+ const sidebarConfigData = userRes.data.data.sidebar_config;
+ // 管理员权限检查基于系统配置,不受用户偏好影响
+ const systemConfig = sidebarConfigData.system || sidebarConfigData;
+ return checkModulePermissionInConfig(systemConfig, modulePath);
+ } else {
+ // 没有配置时,除了系统设置外都允许访问
+ return modulePath !== 'admin.setting';
+ }
+ } catch (error) {
+ console.error('获取侧边栏配置失败:', error);
+ return false;
+ }
+ }
+
+ return false;
+ } catch (error) {
+ console.error('API权限检查失败:', error);
+ return false;
+ }
+ };
+
+ const isUserModuleAllowed = async (modulePath) => {
+ // 数据看板始终允许访问,不受控制台区域开关影响
+ if (modulePath === 'console.detail') {
+ return true;
+ }
+
+ // 普通用户的权限基于最终计算的配置
+ try {
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_config) {
+ const sidebarConfigData = userRes.data.data.sidebar_config;
+ // 使用最终计算的配置进行权限检查
+ const finalConfig = sidebarConfigData.final || sidebarConfigData;
+ return checkModulePermissionInConfig(finalConfig, modulePath);
+ }
+ return false;
+ } catch (error) {
+ console.error('获取用户权限配置失败:', error);
+ return false;
+ }
+ };
+
+ // 检查新的sidebar_config结构中的模块权限
+ const checkModulePermissionInConfig = (sidebarConfig, modulePath) => {
+ const parts = modulePath.split('.');
+ if (parts.length !== 2) {
+ return false;
+ }
+
+ const [sectionKey, moduleKey] = parts;
+ const section = sidebarConfig[sectionKey];
+
+ // 检查区域是否存在且启用
+ if (!section || !section.enabled) {
+ return false;
+ }
+
+ // 检查模块是否启用
+ const moduleValue = section[moduleKey];
+ // 处理布尔值和嵌套对象两种情况
+ if (typeof moduleValue === 'boolean') {
+ return moduleValue === true;
+ } else if (typeof moduleValue === 'object' && moduleValue !== null) {
+ // 对于嵌套对象,检查其enabled状态
+ return moduleValue.enabled === true;
+ }
+ return false;
+ };
+
+ // 权限检查中
+ if (hasPermission === null) {
+ return ;
+ }
+
+ // 无权限
+ if (!hasPermission) {
+ return fallback;
+ }
+
+ // 有权限,渲染子组件
+ return children;
+};
+
+export default ModuleRoute;
From 09374778bd96f0fd7d521ed457d08cf700c18c34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Tue, 2 Sep 2025 19:26:30 +0800
Subject: [PATCH 53/89] =?UTF-8?q?=E6=94=B9=E8=BF=9B"=E4=BE=A7=E8=BE=B9?=
=?UTF-8?q?=E6=A0=8F"=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6-1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0.
---
web/src/components/auth/ModuleRoute.jsx | 200 ------------------------
1 file changed, 200 deletions(-)
delete mode 100644 web/src/components/auth/ModuleRoute.jsx
diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx
deleted file mode 100644
index 3f208c7f..00000000
--- a/web/src/components/auth/ModuleRoute.jsx
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
-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 .
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React, { useState, useEffect, useContext } from 'react';
-import { Navigate } from 'react-router-dom';
-import { StatusContext } from '../../context/Status';
-import Loading from '../common/ui/Loading';
-import { API } from '../../helpers';
-
-/**
- * ModuleRoute - 基于功能模块权限的路由保护组件
- *
- * @param {Object} props
- * @param {React.ReactNode} props.children - 要保护的子组件
- * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token"
- * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden
- * @returns {React.ReactNode}
- */
-const ModuleRoute = ({ children, modulePath, fallback = }) => {
- const [hasPermission, setHasPermission] = useState(null);
- const [statusState] = useContext(StatusContext);
-
- useEffect(() => {
- checkModulePermission();
- }, [modulePath, statusState?.status]); // 只在status数据变化时重新检查
-
- const checkModulePermission = async () => {
- try {
- // 检查用户是否已登录
- const user = localStorage.getItem('user');
- if (!user) {
- setHasPermission(false);
- return;
- }
-
- const userData = JSON.parse(user);
- const userRole = userData.role;
-
- // 超级管理员始终有权限
- if (userRole >= 100) {
- setHasPermission(true);
- return;
- }
-
- // 检查模块权限
- const permission = await checkModulePermissionAPI(modulePath);
-
- // 如果返回null,表示status数据还未加载完成,保持loading状态
- if (permission === null) {
- setHasPermission(null);
- return;
- }
-
- setHasPermission(permission);
- } catch (error) {
- console.error('检查模块权限失败:', error);
- // 出错时采用安全优先策略,拒绝访问
- setHasPermission(false);
- }
- };
-
- const checkModulePermissionAPI = async (modulePath) => {
- try {
- // 数据看板始终允许访问,不受控制台区域开关影响
- if (modulePath === 'console.detail') {
- return true;
- }
-
- // 从StatusContext中获取配置信息
- // 如果status数据还未加载完成,返回null表示需要等待
- if (!statusState?.status) {
- return null;
- }
-
- const user = JSON.parse(localStorage.getItem('user'));
- const userRole = user.role;
-
- // 解析模块路径
- const pathParts = modulePath.split('.');
- if (pathParts.length < 2) {
- return false;
- }
-
- // 普通用户权限检查
- if (userRole < 10) {
- return await isUserModuleAllowed(modulePath);
- }
-
- // 超级管理员权限检查 - 不受系统配置限制
- if (userRole >= 100) {
- return true;
- }
-
- // 管理员权限检查 - 受系统配置限制
- if (userRole >= 10 && userRole < 100) {
- // 从/api/user/self获取系统权限配置
- try {
- const userRes = await API.get('/api/user/self');
- if (userRes.data.success && userRes.data.data.sidebar_config) {
- const sidebarConfigData = userRes.data.data.sidebar_config;
- // 管理员权限检查基于系统配置,不受用户偏好影响
- const systemConfig = sidebarConfigData.system || sidebarConfigData;
- return checkModulePermissionInConfig(systemConfig, modulePath);
- } else {
- // 没有配置时,除了系统设置外都允许访问
- return modulePath !== 'admin.setting';
- }
- } catch (error) {
- console.error('获取侧边栏配置失败:', error);
- return false;
- }
- }
-
- return false;
- } catch (error) {
- console.error('API权限检查失败:', error);
- return false;
- }
- };
-
- const isUserModuleAllowed = async (modulePath) => {
- // 数据看板始终允许访问,不受控制台区域开关影响
- if (modulePath === 'console.detail') {
- return true;
- }
-
- // 普通用户的权限基于最终计算的配置
- try {
- const userRes = await API.get('/api/user/self');
- if (userRes.data.success && userRes.data.data.sidebar_config) {
- const sidebarConfigData = userRes.data.data.sidebar_config;
- // 使用最终计算的配置进行权限检查
- const finalConfig = sidebarConfigData.final || sidebarConfigData;
- return checkModulePermissionInConfig(finalConfig, modulePath);
- }
- return false;
- } catch (error) {
- console.error('获取用户权限配置失败:', error);
- return false;
- }
- };
-
- // 检查新的sidebar_config结构中的模块权限
- const checkModulePermissionInConfig = (sidebarConfig, modulePath) => {
- const parts = modulePath.split('.');
- if (parts.length !== 2) {
- return false;
- }
-
- const [sectionKey, moduleKey] = parts;
- const section = sidebarConfig[sectionKey];
-
- // 检查区域是否存在且启用
- if (!section || !section.enabled) {
- return false;
- }
-
- // 检查模块是否启用
- const moduleValue = section[moduleKey];
- // 处理布尔值和嵌套对象两种情况
- if (typeof moduleValue === 'boolean') {
- return moduleValue === true;
- } else if (typeof moduleValue === 'object' && moduleValue !== null) {
- // 对于嵌套对象,检查其enabled状态
- return moduleValue.enabled === true;
- }
- return false;
- };
-
- // 权限检查中
- if (hasPermission === null) {
- return ;
- }
-
- // 无权限
- if (!hasPermission) {
- return fallback;
- }
-
- // 有权限,渲染子组件
- return children;
-};
-
-export default ModuleRoute;
From 22db389facfd6c79086aa4a8e6a8a48b08120457 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Tue, 16 Sep 2025 12:30:22 +0800
Subject: [PATCH 54/89] feat: vidu video support multi images
---
relay/channel/task/vidu/adaptor.go | 10 ++--------
relay/common/relay_utils.go | 28 ++--------------------------
2 files changed, 4 insertions(+), 34 deletions(-)
diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go
index a1140d1e..8974c614 100644
--- a/relay/channel/task/vidu/adaptor.go
+++ b/relay/channel/task/vidu/adaptor.go
@@ -80,8 +80,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
- // Use the unified validation method for TaskSubmitReq with image-based action determination
- return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
+ return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
@@ -187,14 +186,9 @@ func (a *TaskAdaptor) GetChannelName() string {
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
- var images []string
- if req.Image != "" {
- images = []string{req.Image}
- }
-
r := requestPayload{
Model: defaultString(req.Model, "viduq1"),
- Images: images,
+ Images: req.Images,
Prompt: req.Prompt,
Duration: defaultInt(req.Duration, 5),
Resolution: defaultString(req.Size, "1080p"),
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index cf6d08dd..96d1370b 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -79,34 +79,10 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
req.Images = []string{req.Image}
}
- storeTaskRequest(c, info, action, req)
- return nil
-}
-
-func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
- hasPrompt, ok := requestObj.(HasPrompt)
- if !ok {
- return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
- }
-
- if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
- return taskErr
- }
-
- action := constant.TaskActionTextGenerate
- if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
+ if req.HasImage() {
action = constant.TaskActionGenerate
}
- storeTaskRequest(c, info, action, requestObj)
+ storeTaskRequest(c, info, action, req)
return nil
}
-
-func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
- var req TaskSubmitReq
- if err := c.ShouldBindJSON(&req); err != nil {
- return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
- }
-
- return ValidateTaskRequestWithImage(c, info, req)
-}
From b183f2f6633d5544382d2d6df4ca882f737ac0fb Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Fri, 19 Sep 2025 17:44:58 +0800
Subject: [PATCH 55/89] feat: vidu video add starEnd and reference gen video
---
constant/task.go | 6 ++++--
relay/channel/task/vidu/adaptor.go | 4 ++++
relay/common/relay_utils.go | 8 ++++++++
3 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/constant/task.go b/constant/task.go
index 21790145..e174fd60 100644
--- a/constant/task.go
+++ b/constant/task.go
@@ -11,8 +11,10 @@ const (
SunoActionMusic = "MUSIC"
SunoActionLyrics = "LYRICS"
- TaskActionGenerate = "generate"
- TaskActionTextGenerate = "textGenerate"
+ TaskActionGenerate = "generate"
+ TaskActionTextGenerate = "textGenerate"
+ TaskActionFirstTailGenerate = "firstTailGenerate"
+ TaskActionReferenceGenerate = "referenceGenerate"
)
var SunoModel2Action = map[string]string{
diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go
index 8974c614..358aef58 100644
--- a/relay/channel/task/vidu/adaptor.go
+++ b/relay/channel/task/vidu/adaptor.go
@@ -111,6 +111,10 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
switch info.Action {
case constant.TaskActionGenerate:
path = "/img2video"
+ case constant.TaskActionFirstTailGenerate:
+ path = "/start-end2video"
+ case constant.TaskActionReferenceGenerate:
+ path = "/reference2video"
default:
path = "/text2video"
}
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index 96d1370b..3a721b47 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -81,6 +81,14 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if req.HasImage() {
action = constant.TaskActionGenerate
+ if info.ChannelType == constant.ChannelTypeVidu {
+ // vidu 增加 首尾帧生视频和参考图生视频
+ if len(req.Images) == 2 {
+ action = constant.TaskActionFirstTailGenerate
+ } else if len(req.Images) > 2 {
+ action = constant.TaskActionReferenceGenerate
+ }
+ }
}
storeTaskRequest(c, info, action, req)
From d29fbd378d387ebaf604905cafe4305415b3415e Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Fri, 19 Sep 2025 18:36:44 +0800
Subject: [PATCH 56/89] feat: vidu video add starEnd and reference gen video
show type
---
.../table/task-logs/TaskLogsColumnDefs.jsx | 21 ++++++++++++++++---
web/src/constants/common.constant.js | 2 ++
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
index 766c1715..b63c7dd4 100644
--- a/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
+++ b/web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
@@ -35,8 +35,9 @@ import {
Sparkles,
} from 'lucide-react';
import {
- TASK_ACTION_GENERATE,
- TASK_ACTION_TEXT_GENERATE,
+ TASK_ACTION_FIRST_TAIL_GENERATE,
+ TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
+ TASK_ACTION_TEXT_GENERATE
} from '../../../constants/common.constant';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
@@ -111,6 +112,18 @@ const renderType = (type, t) => {
{t('文生视频')}
);
+ case TASK_ACTION_FIRST_TAIL_GENERATE:
+ return (
+ }>
+ {t('首尾生视频')}
+
+ );
+ case TASK_ACTION_REFERENCE_GENERATE:
+ return (
+ }>
+ {t('参照生视频')}
+
+ );
default:
return (
}>
@@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
const isVideoTask =
record.action === TASK_ACTION_GENERATE ||
- record.action === TASK_ACTION_TEXT_GENERATE;
+ record.action === TASK_ACTION_TEXT_GENERATE ||
+ record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
+ record.action === TASK_ACTION_REFERENCE_GENERATE;
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {
diff --git a/web/src/constants/common.constant.js b/web/src/constants/common.constant.js
index 277bb9a5..57fbbbde 100644
--- a/web/src/constants/common.constant.js
+++ b/web/src/constants/common.constant.js
@@ -40,3 +40,5 @@ export const API_ENDPOINTS = [
export const TASK_ACTION_GENERATE = 'generate';
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
+export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate';
+export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate';
From 7384b0925ebc59837bf3da52bc7872bee57f65ba Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 20 Sep 2025 00:22:54 +0800
Subject: [PATCH 57/89] fix: claude system prompt overwrite
---
relay/claude_handler.go | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index dbdc6ee1..3c9272b6 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"one-api/common"
+ "one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
@@ -69,6 +70,31 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
info.UpstreamModelName = request.Model
}
+ if info.ChannelSetting.SystemPrompt != "" && info.ChannelSetting.SystemPromptOverride {
+ if request.System == nil {
+ request.SetStringSystem(info.ChannelSetting.SystemPrompt)
+ } else if info.ChannelSetting.SystemPromptOverride {
+ common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+ if request.IsStringSystem() {
+ existing := strings.TrimSpace(request.GetStringSystem())
+ if existing == "" {
+ request.SetStringSystem(info.ChannelSetting.SystemPrompt)
+ } else {
+ request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing)
+ }
+ } else {
+ systemContents := request.ParseSystem()
+ newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
+ newSystem.SetText(info.ChannelSetting.SystemPrompt)
+ if len(systemContents) == 0 {
+ request.System = []dto.ClaudeMediaMessage{newSystem}
+ } else {
+ request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...)
+ }
+ }
+ }
+ }
+
var requestBody io.Reader
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
body, err := common.GetRequestBody(c)
From 6f5dd3487cd04f4f8f4f585ea0786c2560c7f316 Mon Sep 17 00:00:00 2001
From: Zhaokun Zhang
Date: Sat, 20 Sep 2025 11:09:28 +0800
Subject: [PATCH 58/89] 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
---
.../common/markdown/MarkdownRenderer.jsx | 4 ++--
web/src/helpers/api.js | 15 ++++++++-------
web/src/helpers/utils.jsx | 18 +++++++++++-------
3 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/web/src/components/common/markdown/MarkdownRenderer.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx
index f1283a64..05419f8c 100644
--- a/web/src/components/common/markdown/MarkdownRenderer.jsx
+++ b/web/src/components/common/markdown/MarkdownRenderer.jsx
@@ -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('代码已复制到剪贴板'));
diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js
index b7092fe7..bc389b2e 100644
--- a/web/src/helpers/api.js
+++ b/web/src/helpers/api.js
@@ -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;
}
});
diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx
index e446ea69..bcd13230 100644
--- a/web/src/helpers/utils.jsx
+++ b/web/src/helpers/utils.jsx
@@ -75,13 +75,17 @@ export async function copy(text) {
await navigator.clipboard.writeText(text);
} catch (e) {
try {
- // 构建input 执行 复制命令
- var _input = window.document.createElement('input');
- _input.value = text;
- window.document.body.appendChild(_input);
- _input.select();
- window.document.execCommand('Copy');
- window.document.body.removeChild(_input);
+ // 构建 textarea 执行复制命令,保留多行文本格式
+ const textarea = window.document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.left = '-9999px';
+ textarea.style.top = '-9999px';
+ window.document.body.appendChild(textarea);
+ textarea.select();
+ window.document.execCommand('copy');
+ window.document.body.removeChild(textarea);
} catch (e) {
okay = false;
console.error(e);
From c0574a0e53369e5715aa31f139b30d4133f2268f Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 20 Sep 2025 13:27:32 +0800
Subject: [PATCH 59/89] feat: add PromptCacheKey field to openai_request struct
---
dto/openai_request.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/dto/openai_request.go b/dto/openai_request.go
index cd05a63c..53adb7f3 100644
--- a/dto/openai_request.go
+++ b/dto/openai_request.go
@@ -777,6 +777,7 @@ type OpenAIResponsesRequest struct {
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store bool `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"`
From eeac99731b05f04fc02fcc2813ea4f4a0991daf7 Mon Sep 17 00:00:00 2001
From: CaIon
Date: Sat, 20 Sep 2025 13:28:33 +0800
Subject: [PATCH 60/89] feat: change ParallelToolCalls and Store fields to
json.RawMessage type
---
dto/openai_request.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dto/openai_request.go b/dto/openai_request.go
index 53adb7f3..191fa638 100644
--- a/dto/openai_request.go
+++ b/dto/openai_request.go
@@ -772,11 +772,11 @@ type OpenAIResponsesRequest struct {
Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
- ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
+ 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 bool `json:"store,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"`
From 3638bf149c5b67f7531223613010016643b7b837 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 20 Sep 2025 13:38:44 +0800
Subject: [PATCH 61/89] fix: gemini system prompt overwrite
---
relay/claude_handler.go | 2 +-
relay/gemini_handler.go | 27 +++++++++++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 3c9272b6..59d12abe 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -70,7 +70,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
info.UpstreamModelName = request.Model
}
- if info.ChannelSetting.SystemPrompt != "" && info.ChannelSetting.SystemPromptOverride {
+ if info.ChannelSetting.SystemPrompt != "" {
if request.System == nil {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
} else if info.ChannelSetting.SystemPromptOverride {
diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go
index 0252d657..1410da60 100644
--- a/relay/gemini_handler.go
+++ b/relay/gemini_handler.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"one-api/common"
+ "one-api/constant"
"one-api/dto"
"one-api/logger"
"one-api/relay/channel/gemini"
@@ -94,6 +95,32 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
adaptor.Init(info)
+ if info.ChannelSetting.SystemPrompt != "" {
+ if request.SystemInstructions == nil {
+ request.SystemInstructions = &dto.GeminiChatContent{
+ Parts: []dto.GeminiPart{
+ {Text: info.ChannelSetting.SystemPrompt},
+ },
+ }
+ } else if len(request.SystemInstructions.Parts) == 0 {
+ request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}
+ } else if info.ChannelSetting.SystemPromptOverride {
+ common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+ merged := false
+ for i := range request.SystemInstructions.Parts {
+ if request.SystemInstructions.Parts[i].Text == "" {
+ continue
+ }
+ request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text
+ merged = true
+ break
+ }
+ if !merged {
+ request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...)
+ }
+ }
+ }
+
// Clean up empty system instruction
if request.SystemInstructions != nil {
hasContent := false
From afecbb07c47e65796b4ca825209eddd0cd9b92ec Mon Sep 17 00:00:00 2001
From: HynoR <20227709+HynoR@users.noreply.github.com>
Date: Sun, 21 Sep 2025 17:26:56 +0900
Subject: [PATCH 62/89] feat: add duplicate key removal function when edit or
add new channel
---
.../channels/modals/EditChannelModal.jsx | 120 ++++++++++++++----
1 file changed, 97 insertions(+), 23 deletions(-)
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index c0a21624..03becc2f 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -802,7 +802,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]);
}
}
}
@@ -899,6 +901,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 +1046,41 @@ const EditChannelModal = (props) => {
)}
{batch && (
- {
- setMultiToSingle((prev) => !prev);
- setInputs((prev) => {
- const newInputs = { ...prev };
- if (!multiToSingle) {
- newInputs.multi_key_mode = multiKeyMode;
- } else {
- delete newInputs.multi_key_mode;
- }
- return newInputs;
- });
- }}
- >
- {t('密钥聚合模式')}
-
+ <>
+ {
+ 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('密钥聚合模式')}
+
+
+ {inputs.type !== 41 && (
+
+ )}
+ >
)}
) : null;
@@ -1198,7 +1267,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 +1290,8 @@ const EditChannelModal = (props) => {
/>
)}
{batch ? (
- inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+ inputs.type === 41 &&
+ (inputs.vertex_key_type || 'json') === 'json' ? (
{
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={
-
+
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
@@ -1282,7 +1355,8 @@ const EditChannelModal = (props) => {
)
) : (
<>
- {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+ {inputs.type === 41 &&
+ (inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
From 5545e70a426953bfd97f244473f295e4bc5b459f Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Fri, 26 Sep 2025 15:32:59 +0800
Subject: [PATCH 63/89] feat: amazon nova model
---
relay/channel/aws/constants.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 72d0f989..3a28c95c 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -21,6 +21,10 @@ var awsModelIDMap = map[string]string{
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
+ "nova-canvas-v1:0": "amazon.nova-canvas-v1:0",
+ "nova-reel-v1:0": "amazon.nova-reel-v1:0",
+ "nova-reel-v1:1": "amazon.nova-reel-v1:1",
+ "nova-sonic-v1:0": "amazon.nova-sonic-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
From a069d03ef716a2679a62ae06e6ad8e4a9784fb0a Mon Sep 17 00:00:00 2001
From: huanghejian
Date: Fri, 26 Sep 2025 15:55:00 +0800
Subject: [PATCH 64/89] feat: amazon nova model
---
relay/channel/aws/constants.go | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go
index 3a28c95c..5ac7ce99 100644
--- a/relay/channel/aws/constants.go
+++ b/relay/channel/aws/constants.go
@@ -86,10 +86,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"apac": true,
},
"amazon.nova-premier-v1:0": {
+ "us": true,
+ },
+ "amazon.nova-canvas-v1:0": {
"us": true,
"eu": true,
"apac": true,
- }}
+ },
+ "amazon.nova-reel-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ },
+ "amazon.nova-reel-v1:1": {
+ "us": true,
+ },
+ "amazon.nova-sonic-v1:0": {
+ "us": true,
+ "eu": true,
+ "apac": true,
+ },
+}
var awsRegionCrossModelPrefixMap = map[string]string{
"us": "us",
From b3b6ec0375d604f1dbb786c69d182d558572a706 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 27 Sep 2025 00:15:28 +0800
Subject: [PATCH 65/89] fix: add missing fields to Gemini request
---
dto/gemini.go | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/dto/gemini.go b/dto/gemini.go
index 5df67ba0..ad2ddb8b 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -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 {
@@ -239,12 +262,20 @@ type GeminiChatGenerationConfig struct {
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
ResponseSchema any `json:"responseSchema,omitempty"`
+ ResponseJsonSchema any `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"`
From b66316fe6f593921de0a0685880a396969bdfeca Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 27 Sep 2025 00:24:29 +0800
Subject: [PATCH 66/89] fix: jsonRaw
---
dto/gemini.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dto/gemini.go b/dto/gemini.go
index ad2ddb8b..b1f7b9a4 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -261,8 +261,8 @@ type GeminiChatGenerationConfig struct {
CandidateCount int `json:"candidateCount,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
- ResponseSchema any `json:"responseSchema,omitempty"`
- ResponseJsonSchema any `json:"responseJsonSchema,omitempty"`
+ ResponseSchema json.RawMessage `json:"responseSchema,omitempty"`
+ ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
ResponseLogprobs bool `json:"responseLogprobs,omitempty"`
From 53513cbe1d0c9690d601f7b7050b205a0770df27 Mon Sep 17 00:00:00 2001
From: Seefs
Date: Sat, 27 Sep 2025 00:33:05 +0800
Subject: [PATCH 67/89] fix: jsonRaw
---
dto/gemini.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dto/gemini.go b/dto/gemini.go
index b1f7b9a4..bc05c6aa 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -261,7 +261,7 @@ type GeminiChatGenerationConfig struct {
CandidateCount int `json:"candidateCount,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
- ResponseSchema json.RawMessage `json:"responseSchema,omitempty"`
+ ResponseSchema any `json:"responseSchema,omitempty"`
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
From ee6e5ff8828e6c9ef77015d5a43a910e04cdeacf Mon Sep 17 00:00:00 2001
From: RedwindA
Date: Sat, 27 Sep 2025 01:19:09 +0800
Subject: [PATCH 68/89] =?UTF-8?q?feat:=20=E4=BB=85=E4=B8=BA=E9=80=82?=
=?UTF-8?q?=E5=BD=93=E7=9A=84=E6=B8=A0=E9=81=93=E6=B8=B2=E6=9F=93=E8=8E=B7?=
=?UTF-8?q?=E5=8F=96=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E6=8C=89=E9=92=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../channels/modals/EditChannelModal.jsx | 36 +++++++++++++++----
1 file changed, 29 insertions(+), 7 deletions(-)
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index c0a21624..967bf88a 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -85,6 +85,26 @@ 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,
+]);
+
function type2secretPrompt(type) {
// inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
switch (type) {
@@ -1872,13 +1892,15 @@ const EditChannelModal = (props) => {
>
{t('填入所有模型')}
-
+ {MODEL_FETCHABLE_TYPES.has(inputs.type) && (
+
+ )}
@@ -179,7 +185,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 +226,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 +269,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 +302,56 @@ const AccountManagement = ({
{status.telegram_oauth ? (
- userState.user.telegram_id !== '' ? (
-
+ isBound(userState.user?.telegram_id) ? (
+
{t('已绑定')}
) : (
-
-
-
+ setShowTelegramBindModal(true)}
+ >
+ {t('绑定')}
+
)
) : (
-
+
{t('未启用')}
)}
+ setShowTelegramBindModal(false)}
+ footer={null}
+ >
+
+ {t('点击下方按钮通过 Telegram 完成绑定')}
+
+
+
{/* LinuxDO绑定 */}
@@ -350,8 +384,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('未启用')}
From 1b51a7e311c29dc70d86aa43c4ae3c885f13bee4 Mon Sep 17 00:00:00 2001
From: RedwindA
Date: Sun, 28 Sep 2025 17:38:56 +0800
Subject: [PATCH 88/89] fix(settings): ensure turnstile settings are reset when
disabled
---
web/src/components/settings/PersonalSetting.jsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index d86a810d..15dfbd97 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -85,6 +85,9 @@ const PersonalSetting = () => {
if (parsed.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(parsed.turnstile_site_key);
+ } else {
+ setTurnstileEnabled(false);
+ setTurnstileSiteKey('');
}
}
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
@@ -98,6 +101,9 @@ const PersonalSetting = () => {
if (data.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(data.turnstile_site_key);
+ } else {
+ setTurnstileEnabled(false);
+ setTurnstileSiteKey('');
}
}
} catch (e) {
From e271e0994506e3e8d3d5cc8868b067b9df2413ab Mon Sep 17 00:00:00 2001
From: Seefs
Date: Mon, 29 Sep 2025 12:15:38 +0800
Subject: [PATCH 89/89] fix: set volcengine default url
---
.../components/table/channels/modals/EditChannelModal.jsx | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index d795d548..2eb480e7 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -455,6 +455,14 @@ const EditChannelModal = (props) => {
data.is_enterprise_account = 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);
if (formApiRef.current) {
formApiRef.current.setValues(data);