diff --git a/controller/relay.go b/controller/relay.go index 778fd9aaa26..dc5bb5a1c64 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -141,7 +141,16 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { contains, words := service.CheckSensitiveText(meta.CombineText) if contains { logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) - newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + if service.WriteSensitiveRefusal(c, relayFormat, relayInfo, request) { + return + } + // fallback: unsupported format, return explicit 400 refusal + newAPIError = types.NewErrorWithStatusCode( + errors.New(service.GetSensitiveRefusalText()), + types.ErrorCodeSensitiveWordsDetected, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) return } } diff --git a/model/option.go b/model/option.go index c649af0c332..e83640cb8e7 100644 --- a/model/option.go +++ b/model/option.go @@ -193,6 +193,7 @@ func InitOptionMap() { common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled) common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled) common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() + common.OptionMap["SensitiveRefusalText"] = setting.SensitiveRefusalText common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() @@ -629,6 +630,8 @@ func updateOptionMap(key string, value string) (err error) { common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64) case "SensitiveWords": setting.SensitiveWordsFromString(value) + case "SensitiveRefusalText": + setting.SensitiveRefusalText = value case "AutomaticDisableKeywords": operation_setting.AutomaticDisableKeywordsFromString(value) case "AutomaticDisableStatusCodes": diff --git a/service/sensitive_refusal.go b/service/sensitive_refusal.go new file mode 100644 index 00000000000..808054e00b9 --- /dev/null +++ b/service/sensitive_refusal.go @@ -0,0 +1,223 @@ +package service + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayhelper "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/types" +) + +const defaultSensitiveRefusalText = "很抱歉,你输入的内容含有敏感信息,我无法回答。" + +func GetSensitiveRefusalText() string { + if setting.SensitiveRefusalText != "" { + return setting.SensitiveRefusalText + } + return defaultSensitiveRefusalText +} + +// WriteSensitiveRefusal writes a well-formed refusal response (200 OK) when sensitive +// words are detected. Returns true when the response was written successfully; false +// means the caller should fall back to returning a 400 error instead. +func WriteSensitiveRefusal(c *gin.Context, relayFormat types.RelayFormat, relayInfo *relaycommon.RelayInfo, request dto.Request) bool { + text := GetSensitiveRefusalText() + + switch relayFormat { + case types.RelayFormatOpenAI: + // Only chat/completions requests have Messages; /v1/completions (Prompt) and + // /v1/moderations (Input) share the same RelayFormat but have different schemas, + // so fall back to 400 for those. + r, ok := request.(*dto.GeneralOpenAIRequest) + if !ok || len(r.Messages) == 0 { + return false + } + if relayInfo != nil && relayInfo.IsStream { + return writeOpenAIStreamRefusal(c, text, relayInfo) + } + return writeOpenAIRefusal(c, text, relayInfo) + + case types.RelayFormatClaude: + if relayInfo != nil && relayInfo.IsStream { + return writeClaudeStreamRefusal(c, text, relayInfo) + } + return writeClaudeRefusal(c, text, relayInfo) + + default: + // RelayFormatOpenAIResponses, RelayFormatOpenAIResponsesCompaction, + // RelayFormatGemini, RelayFormatEmbedding, RelayFormatRerank, + // RelayFormatOpenAIAudio, RelayFormatOpenAIImage, + // RelayFormatTask, RelayFormatMjProxy, RelayFormatOpenAIRealtime + // — these have endpoint-specific schemas; fall back to 400 error. + return false + } +} + +func writeOpenAIRefusal(c *gin.Context, text string, relayInfo *relaycommon.RelayInfo) bool { + model := "" + if relayInfo != nil { + model = relayInfo.OriginModelName + } + resp := dto.OpenAITextResponse{ + Id: fmt.Sprintf("chatcmpl-sensitive-%d", time.Now().UnixNano()), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: model, + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: text, + }, + FinishReason: "stop", + }, + }, + Usage: dto.Usage{}, + } + c.JSON(http.StatusOK, resp) + return true +} + +func writeOpenAIStreamRefusal(c *gin.Context, text string, relayInfo *relaycommon.RelayInfo) bool { + model := "" + if relayInfo != nil { + model = relayInfo.OriginModelName + } + id := fmt.Sprintf("chatcmpl-sensitive-%d", time.Now().UnixNano()) + created := time.Now().Unix() + stop := "stop" + + content := text + delta := dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: &content, + }, + }, + }, + } + finishChunk := dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{}, + FinishReason: &stop, + }, + }, + } + + deltaJson, err1 := common.Marshal(delta) + finishJson, err2 := common.Marshal(finishChunk) + if err1 != nil || err2 != nil { + return false + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + c.Render(-1, common.CustomEvent{Data: "data: " + string(deltaJson)}) + c.Render(-1, common.CustomEvent{Data: "data: " + string(finishJson)}) + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + _ = relayhelper.FlushWriter(c) + return true +} + +func writeClaudeRefusal(c *gin.Context, text string, relayInfo *relaycommon.RelayInfo) bool { + model := "" + if relayInfo != nil { + model = relayInfo.OriginModelName + } + textCopy := text + resp := dto.ClaudeResponse{ + Id: fmt.Sprintf("msg_sensitive_%d", time.Now().UnixNano()), + Type: "message", + Role: "assistant", + Model: model, + Content: []dto.ClaudeMediaMessage{ + {Type: "text", Text: &textCopy}, + }, + StopReason: "end_turn", + Usage: &dto.ClaudeUsage{}, + } + c.JSON(http.StatusOK, resp) + return true +} + +func writeClaudeStreamRefusal(c *gin.Context, text string, relayInfo *relaycommon.RelayInfo) bool { + model := "" + if relayInfo != nil { + model = relayInfo.OriginModelName + } + msgId := fmt.Sprintf("msg_sensitive_%d", time.Now().UnixNano()) + textCopy := text + index := 0 + emptyStr := "" + stopReason := "end_turn" + + events := []dto.ClaudeResponse{ + { + Type: "message_start", + Message: &dto.ClaudeMediaMessage{ + Id: msgId, + Type: "message", + Role: "assistant", + Model: model, + Usage: &dto.ClaudeUsage{}, + }, + }, + { + Type: "content_block_start", + Index: &index, + ContentBlock: &dto.ClaudeMediaMessage{Type: "text", Text: &emptyStr}, + }, + { + Type: "content_block_delta", + Index: &index, + Delta: &dto.ClaudeMediaMessage{Type: "text_delta", Text: &textCopy}, + }, + { + Type: "content_block_stop", + Index: &index, + }, + { + // stop_reason goes inside delta per Anthropic SSE spec + Type: "message_delta", + Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason}, + Usage: &dto.ClaudeUsage{}, + }, + { + Type: "message_stop", + }, + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + + for _, ev := range events { + _ = relayhelper.ClaudeData(c, ev) + } + return true +} diff --git a/setting/sensitive.go b/setting/sensitive.go index 86f9be9a6eb..a8f988ce17b 100644 --- a/setting/sensitive.go +++ b/setting/sensitive.go @@ -13,6 +13,9 @@ var StopOnSensitiveEnabled = true // StreamCacheQueueLength 流模式缓存队列长度,0表示无缓存 var StreamCacheQueueLength = 0 +// SensitiveRefusalText 敏感词命中时返回给用户的拒绝文案,空则使用默认值 +var SensitiveRefusalText = "" + // SensitiveWords 敏感词 // var SensitiveWords []string var SensitiveWords = []string{ diff --git a/web/classic/src/components/settings/OperationSetting.jsx b/web/classic/src/components/settings/OperationSetting.jsx index 8585a3e9027..9c186b8a217 100644 --- a/web/classic/src/components/settings/OperationSetting.jsx +++ b/web/classic/src/components/settings/OperationSetting.jsx @@ -60,6 +60,7 @@ const OperationSetting = () => { CheckSensitiveEnabled: false, CheckSensitiveOnPromptEnabled: false, SensitiveWords: '', + SensitiveRefusalText: '', /* 日志设置 */ LogConsumeEnabled: false, diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 08724adde58..7ea09df7e35 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -4038,6 +4038,7 @@ "该记录未上传企业认证图片": "This record has no enterprise certification images", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "Reset this certification? The user's status will be cleared and all info (including the business license and legal-rep ID images) must be resubmitted.", "企业认证状态": "Enterprise Certification Status", - "企业认证审核管理": "Enterprise certification review management" + "企业认证审核管理": "Enterprise certification review management", + "留空则使用内置默认拒绝文案": "Leave blank to use the built-in default refusal message" } -} +} \ No newline at end of file diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index 7d9592ff0da..462fe56431c 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -3851,6 +3851,7 @@ "该记录未上传企业认证图片": "Cet enregistrement n'a pas d'images de certification d'entreprise", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "Réinitialiser cette certification ? Le statut de l'utilisateur sera effacé et toutes les informations (y compris la licence commerciale et les pièces d'identité du représentant légal) devront être resoumises.", "企业认证状态": "Statut de certification d'entreprise", - "企业认证审核管理": "Gestion de l'examen de la certification d'entreprise" + "企业认证审核管理": "Gestion de l'examen de la certification d'entreprise", + "留空则使用内置默认拒绝文案": "Laisser vide pour utiliser le message de refus par défaut intégré" } -} +} \ No newline at end of file diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index 7c746656de5..740e2a211c9 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -3820,6 +3820,7 @@ "该记录未上传企业认证图片": "このレコードには企業認証画像がありません", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "この認証をリセットしますか?ユーザーの状態がクリアされ、すべての情報(営業許可証および法人代表者の身分証画像を含む)を再提出する必要があります。", "企业认证状态": "企業認証ステータス", - "企业认证审核管理": "企業認証審査管理" + "企业认证审核管理": "企業認証審査管理", + "留空则使用内置默认拒绝文案": "空白にするとデフォルトの拒否メッセージを使用します" } -} +} \ No newline at end of file diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index e97eae3120b..c82fef8d71d 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -3871,6 +3871,7 @@ "该记录未上传企业认证图片": "К этой записи не прикреплены изображения корпоративной верификации", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "Сбросить эту верификацию? Статус пользователя будет очищен, и все данные (включая лицензию и удостоверение представителя) нужно будет отправить заново.", "企业认证状态": "Статус корпоративной верификации", - "企业认证审核管理": "Управление проверкой корпоративной верификации" + "企业认证审核管理": "Управление проверкой корпоративной верификации", + "留空则使用内置默认拒绝文案": "Оставьте пустым для использования встроенного сообщения об отказе по умолчанию" } -} +} \ No newline at end of file diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 592605345c0..81985087440 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -4379,6 +4379,7 @@ "该记录未上传企业认证图片": "Bản ghi này chưa tải lên hình ảnh xác minh doanh nghiệp", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "Đặt lại xác minh này? Trạng thái người dùng sẽ bị xóa và phải gửi lại toàn bộ thông tin (bao gồm giấy phép kinh doanh và ảnh CMND/CCCD người đại diện).", "企业认证状态": "Trạng thái xác minh doanh nghiệp", - "企业认证审核管理": "Quản lý xét duyệt xác minh doanh nghiệp" + "企业认证审核管理": "Quản lý xét duyệt xác minh doanh nghiệp", + "留空则使用内置默认拒绝文案": "Để trống để sử dụng thông báo từ chối mặc định tích hợp" } -} +} \ No newline at end of file diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index e068dfaf3c1..bdfde604287 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -1599,6 +1599,7 @@ "拉取模型": "拉取模型", "拉取进度": "拉取进度", "拒绝提示模板(可选)": "拒绝提示模板(可选)", + "留空则使用内置默认拒绝文案": "留空则使用内置默认拒绝文案", "拦截原因": "拦截原因", "按K显示单位": "按K显示单位", "按价格设置": "按价格设置", diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index 63f5c390bda..c8bee16a4eb 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -3844,6 +3844,7 @@ "该记录未上传企业认证图片": "該記錄未上傳企業認證圖片", "确认重置该认证?用户认证状态将清空,需重新提交所有信息(含营业执照与法人证件图片)。": "確認重置該認證?使用者認證狀態將清空,需重新提交所有資訊(含營業執照與法人證件圖片)。", "企业认证状态": "企業認證狀態", - "企业认证审核管理": "企業認證審核管理" + "企业认证审核管理": "企業認證審核管理", + "留空则使用内置默认拒绝文案": "" } -} +} \ No newline at end of file diff --git a/web/classic/src/pages/Setting/Operation/SettingsSensitiveWords.jsx b/web/classic/src/pages/Setting/Operation/SettingsSensitiveWords.jsx index 8310ddb2110..696a704d8aa 100644 --- a/web/classic/src/pages/Setting/Operation/SettingsSensitiveWords.jsx +++ b/web/classic/src/pages/Setting/Operation/SettingsSensitiveWords.jsx @@ -35,6 +35,7 @@ export default function SettingsSensitiveWords(props) { CheckSensitiveEnabled: false, CheckSensitiveOnPromptEnabled: false, SensitiveWords: '', + SensitiveRefusalText: '', }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); @@ -144,6 +145,23 @@ export default function SettingsSensitiveWords(props) { /> + + + + setInputs({ + ...inputs, + SensitiveRefusalText: value, + }) + } + autosize={{ minRows: 2, maxRows: 6 }} + /> + +