From 9c243d1fb8c02bb1eed3f364c9492733c3bc60b7 Mon Sep 17 00:00:00 2001 From: Your Name <3094777083@qq.com> Date: Sat, 27 Dec 2025 18:22:30 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(gemini):=20=E6=94=AF=E6=8C=81=20tool?= =?UTF-8?q?=5Fchoice=20=E5=8F=82=E6=95=B0=E8=BD=AC=E6=8D=A2=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/relay-gemini.go | 128 +++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 7 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index db5ea489..20a352dd 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -356,6 +356,13 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i }) } geminiRequest.SetTools(geminiTools) + + // [NEW] Convert OpenAI tool_choice to Gemini toolConfig.functionCallingConfig + // Mapping: "auto" -> "AUTO", "none" -> "NONE", "required" -> "ANY" + // Object format: {"type": "function", "function": {"name": "xxx"}} -> "ANY" + allowedFunctionNames + if textRequest.ToolChoice != nil { + geminiRequest.ToolConfig = convertToolChoiceToGeminiConfig(textRequest.ToolChoice) + } } if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { @@ -960,6 +967,24 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) choice.FinishReason = constant.FinishReasonStop case "MAX_TOKENS": choice.FinishReason = constant.FinishReasonLength + case "SAFETY": + // Safety filter triggered + choice.FinishReason = constant.FinishReasonContentFilter + case "RECITATION": + // Recitation (citation) detected + choice.FinishReason = constant.FinishReasonContentFilter + case "BLOCKLIST": + // Blocklist triggered + choice.FinishReason = constant.FinishReasonContentFilter + case "PROHIBITED_CONTENT": + // Prohibited content detected + choice.FinishReason = constant.FinishReasonContentFilter + case "SPII": + // Sensitive personally identifiable information + choice.FinishReason = constant.FinishReasonContentFilter + case "OTHER": + // Other reasons + choice.FinishReason = constant.FinishReasonContentFilter default: choice.FinishReason = constant.FinishReasonContentFilter } @@ -991,13 +1016,34 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d isTools := false isThought := false if candidate.FinishReason != nil { - // p := GeminiConvertFinishReason(*candidate.FinishReason) + // Map Gemini FinishReason to OpenAI finish_reason switch *candidate.FinishReason { case "STOP": + // Normal completion choice.FinishReason = &constant.FinishReasonStop case "MAX_TOKENS": + // Reached maximum token limit choice.FinishReason = &constant.FinishReasonLength + case "SAFETY": + // Safety filter triggered + choice.FinishReason = &constant.FinishReasonContentFilter + case "RECITATION": + // Recitation (citation) detected + choice.FinishReason = &constant.FinishReasonContentFilter + case "BLOCKLIST": + // Blocklist triggered + choice.FinishReason = &constant.FinishReasonContentFilter + case "PROHIBITED_CONTENT": + // Prohibited content detected + choice.FinishReason = &constant.FinishReasonContentFilter + case "SPII": + // Sensitive personally identifiable information + choice.FinishReason = &constant.FinishReasonContentFilter + case "OTHER": + // Other reasons + choice.FinishReason = &constant.FinishReasonContentFilter default: + // Unknown reason, treat as content filter choice.FinishReason = &constant.FinishReasonContentFilter } } @@ -1214,12 +1260,20 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if len(geminiResponse.Candidates) == 0 { - //return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) - //if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { - // return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest) - //} else { - // return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) - //} + // [FIX] Return meaningful error when Candidates is empty + if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + return nil, types.NewOpenAIError( + errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), + types.ErrorCodePromptBlocked, + http.StatusBadRequest, + ) + } else { + return nil, types.NewOpenAIError( + errors.New("empty response from Gemini API"), + types.ErrorCodeEmptyResponse, + http.StatusInternalServerError, + ) + } } fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName @@ -1362,3 +1416,63 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. return usage, nil } + +// convertToolChoiceToGeminiConfig converts OpenAI tool_choice to Gemini toolConfig +// OpenAI tool_choice values: +// - "auto": Let the model decide (default) +// - "none": Don't call any tools +// - "required": Must call at least one tool +// - {"type": "function", "function": {"name": "xxx"}}: Call specific function +// +// Gemini functionCallingConfig.mode values: +// - "AUTO": Model decides whether to call functions +// - "NONE": Model won't call functions +// - "ANY": Model must call at least one function +func convertToolChoiceToGeminiConfig(toolChoice any) *dto.ToolConfig { + if toolChoice == nil { + return nil + } + + // Handle string values: "auto", "none", "required" + if toolChoiceStr, ok := toolChoice.(string); ok { + config := &dto.ToolConfig{ + FunctionCallingConfig: &dto.FunctionCallingConfig{}, + } + switch toolChoiceStr { + case "auto": + config.FunctionCallingConfig.Mode = "AUTO" + case "none": + config.FunctionCallingConfig.Mode = "NONE" + case "required": + config.FunctionCallingConfig.Mode = "ANY" + default: + // Unknown string value, default to AUTO + config.FunctionCallingConfig.Mode = "AUTO" + } + return config + } + + // Handle object value: {"type": "function", "function": {"name": "xxx"}} + if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok { + if toolChoiceMap["type"] == "function" { + config := &dto.ToolConfig{ + FunctionCallingConfig: &dto.FunctionCallingConfig{ + Mode: "ANY", + }, + } + // Extract function name if specified + if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok { + if name, ok := function["name"].(string); ok && name != "" { + config.FunctionCallingConfig.AllowedFunctionNames = []string{name} + } + } + return config + } + // Unsupported map structure (type is not "function"), return nil + return nil + } + + // Unsupported type, return nil + return nil +} + From 7af4d0784395c77cb7d80214560288be4157f90c Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 14:32:51 +0800 Subject: [PATCH 2/8] fix: Charge locally even if there's an error --- relay/channel/gemini/relay-gemini.go | 37 +++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index f4b6bbf7..22ddfaea 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1355,20 +1355,50 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if len(geminiResponse.Candidates) == 0 { - // [FIX] Return meaningful error when Candidates is empty + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + } + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + if usage.PromptTokens <= 0 { + usage.PromptTokens = info.GetEstimatePromptTokens() + } + + var newAPIError *types.NewAPIError if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { - return nil, types.NewOpenAIError( + newAPIError = types.NewOpenAIError( errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest, ) } else { - return nil, types.NewOpenAIError( + newAPIError = types.NewOpenAIError( errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError, ) } + + service.ResetStatusCode(newAPIError, c.GetString("status_code_mapping")) + + switch info.RelayFormat { + case types.RelayFormatClaude: + c.JSON(newAPIError.StatusCode, gin.H{ + "type": "error", + "error": newAPIError.ToClaudeError(), + }) + default: + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + return &usage, nil } fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) fullTextResponse.Model = info.UpstreamModelName @@ -1643,4 +1673,3 @@ func convertToolChoiceToGeminiConfig(toolChoice any) *dto.ToolConfig { // Unsupported type, return nil return nil } - From 68e1e635e9062b7bc9c83f39258fe319c604f414 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 14:52:18 +0800 Subject: [PATCH 3/8] feat: logs show reject reason --- constant/context_key.go | 4 ++++ model/log.go | 1 + relay/channel/gemini/relay-gemini-native.go | 6 ++++++ relay/channel/gemini/relay-gemini.go | 6 ++++++ relay/channel/openai/relay-openai.go | 7 +++++++ relay/compatible_handler.go | 6 ++++++ .../components/table/usage-logs/UsageLogsColumnDefs.jsx | 3 +++ 7 files changed, 33 insertions(+) diff --git a/constant/context_key.go b/constant/context_key.go index 833aabae..b494f368 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -55,4 +55,8 @@ const ( ContextKeyLocalCountTokens ContextKey = "local_count_tokens" ContextKeySystemPromptOverride ContextKey = "system_prompt_override" + + // ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses. + // It is not returned to end users, but can be persisted into consume/error logs for debugging. + ContextKeyAdminRejectReason ContextKey = "admin_reject_reason" ) diff --git a/model/log.go b/model/log.go index f8940c15..872d73d4 100644 --- a/model/log.go +++ b/model/log.go @@ -59,6 +59,7 @@ func formatUserLogs(logs []*Log) { // Remove admin-only debug fields. delete(otherMap, "admin_info") delete(otherMap, "request_conversion") + delete(otherMap, "reject_reason") } logs[i].Other = common.MapToJsonStr(otherMap) logs[i].Id = logs[i].Id % 1024 diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index 5f9ff7cd..cd9d06db 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -1,10 +1,12 @@ package gemini import ( + "fmt" "io" "net/http" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -35,6 +37,10 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) + } + // 计算使用量(基于 UsageMetadata) usage := dto.Usage{ PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 22ddfaea..77e335c5 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1197,6 +1197,10 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http return false } + if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) + } + // 统计图片数量 for _, candidate := range geminiResponse.Candidates { for _, part := range candidate.Content.Parts { @@ -1372,12 +1376,14 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R var newAPIError *types.NewAPIError if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) newAPIError = types.NewOpenAIError( errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest, ) } else { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates") newAPIError = types.NewOpenAIError( errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a4c6ef60..a4de1611 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -229,6 +229,13 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } + for _, choice := range simpleResponse.Choices { + if choice.FinishReason == constant.FinishReasonContentFilter { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "openai_finish_reason=content_filter") + break + } + } + forceFormat := false if info.ChannelSetting.ForceFormat { forceFormat = true diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index eab5052d..5792715b 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -237,6 +237,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } extraContent = append(extraContent, "上游无计费信息") } + + adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason) + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() promptTokens := usage.PromptTokens cacheTokens := usage.PromptTokensDetails.CachedTokens @@ -461,6 +464,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage } logContent := strings.Join(extraContent, ", ") other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + if adminRejectReason != "" { + other["reject_reason"] = adminRejectReason + } // For chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. if isClaudeUsageSemantic { other["claude"] = true diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index b3096c28..84e57ea6 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -578,6 +578,9 @@ export const getLogsColumns = ({ other?.is_system_prompt_overwritten, 'openai', ); + if (isAdminUser && other?.reject_reason) { + content += `\nBlock reason: ${other.reject_reason}`; + } return ( Date: Sun, 25 Jan 2026 15:00:30 +0800 Subject: [PATCH 4/8] feat: logs show reject reason --- web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx | 3 --- web/src/hooks/usage-logs/useUsageLogsData.jsx | 6 ++++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 84e57ea6..b3096c28 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -578,9 +578,6 @@ export const getLogsColumns = ({ other?.is_system_prompt_overwritten, 'openai', ); - if (isAdminUser && other?.reject_reason) { - content += `\nBlock reason: ${other.reject_reason}`; - } return ( { value: logs[i].content, }); } + if (isAdminUser && other?.reject_reason) { + expandDataLocal.push({ + key: t('拦截原因'), + value: other.reject_reason, + }); + } } if (logs[i].type === 2) { let modelMapped = From 0e76cc3f67a12c6ccb8c066247f5866c74855a58 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 15:21:31 +0800 Subject: [PATCH 5/8] feat: claude refusal reason --- relay/channel/claude/relay-claude.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index d3986236..f5872011 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/channel/openrouter" @@ -41,6 +42,15 @@ func stopReasonClaude2OpenAI(reason string) string { } } +func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { + if c == nil { + return + } + if strings.EqualFold(stopReason, "refusal") { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "claude_stop_reason=refusal") + } +} + func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.ClaudeRequest { claudeRequest := dto.ClaudeRequest{ @@ -644,6 +654,12 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } + if claudeResponse.StopReason != "" { + maybeMarkClaudeRefusal(c, claudeResponse.StopReason) + } + if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil { + maybeMarkClaudeRefusal(c, *claudeResponse.Delta.StopReason) + } if info.RelayFormat == types.RelayFormatClaude { FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) @@ -735,6 +751,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { return types.WithClaudeError(*claudeError, http.StatusInternalServerError) } + maybeMarkClaudeRefusal(c, claudeResponse.StopReason) if requestMode == RequestModeCompletion { claudeInfo.Usage = service.ResponseText2Usage(c, claudeResponse.Completion, info.UpstreamModelName, info.GetEstimatePromptTokens()) } else { From d829958f53ed583a9866c9bde8e901224c0a53e5 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 15:31:23 +0800 Subject: [PATCH 6/8] fix: reason convert --- relay/channel/claude/relay-claude.go | 14 ++-------- relay/reasonmap/reasonmap.go | 41 ++++++++++++++++++++++++++++ service/convert.go | 16 ++--------- 3 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 relay/reasonmap/reasonmap.go diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index f5872011..a17dc75e 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openrouter" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/relay/reasonmap" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/types" @@ -28,18 +29,7 @@ const ( ) func stopReasonClaude2OpenAI(reason string) string { - switch reason { - case "stop_sequence": - return "stop" - case "end_turn": - return "stop" - case "max_tokens": - return "length" - case "tool_use": - return "tool_calls" - default: - return reason - } + return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason) } func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) { diff --git a/relay/reasonmap/reasonmap.go b/relay/reasonmap/reasonmap.go new file mode 100644 index 00000000..45b74bb1 --- /dev/null +++ b/relay/reasonmap/reasonmap.go @@ -0,0 +1,41 @@ +package reasonmap + +import ( + "strings" + + "github.com/QuantumNous/new-api/constant" +) + +func ClaudeStopReasonToOpenAIFinishReason(stopReason string) string { + switch strings.ToLower(stopReason) { + case "stop_sequence": + return "stop" + case "end_turn": + return "stop" + case "max_tokens": + return "length" + case "tool_use": + return "tool_calls" + case "refusal": + return constant.FinishReasonContentFilter + default: + return stopReason + } +} + +func OpenAIFinishReasonToClaudeStopReason(finishReason string) string { + switch strings.ToLower(finishReason) { + case "stop": + return "end_turn" + case "stop_sequence": + return "stop_sequence" + case "length", "max_tokens": + return "max_tokens" + case constant.FinishReasonContentFilter: + return "refusal" + case "tool_calls": + return "tool_use" + default: + return finishReason + } +} diff --git a/service/convert.go b/service/convert.go index f357f684..26534e40 100644 --- a/service/convert.go +++ b/service/convert.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel/openrouter" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/reasonmap" ) func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { @@ -540,20 +541,7 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco } func stopReasonOpenAI2Claude(reason string) string { - switch reason { - case "stop": - return "end_turn" - case "stop_sequence": - return "stop_sequence" - case "length": - fallthrough - case "max_tokens": - return "max_tokens" - case "tool_calls": - return "tool_use" - default: - return reason - } + return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason) } func toJSONString(v interface{}) string { From 2548eaf8d38108862cf6a7e05f4f2418abb07c10 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 16:07:37 +0800 Subject: [PATCH 7/8] feat: xai refusal reason --- relay/channel/openai/relay-openai.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a4de1611..c9c71327 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -1,6 +1,7 @@ package openai import ( + "bytes" "fmt" "io" "net/http" @@ -22,6 +23,19 @@ import ( "github.com/gorilla/websocket" ) +const xaiCSAMSafetyCheckType = "SAFETY_CHECK_TYPE_CSAM" + +func maybeMarkXaiCSAMRefusal(c *gin.Context, info *relaycommon.RelayInfo, responseBody []byte) bool { + if c == nil || info == nil || len(responseBody) == 0 { + return false + } + if !bytes.Contains(responseBody, []byte(xaiCSAMSafetyCheckType)) { + return false + } + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "grok_safety_check_type=csam") + return true +} + func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { if data == "" { return nil @@ -201,6 +215,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } + isXaiCSAMRefusal := maybeMarkXaiCSAMRefusal(c, info, responseBody) if common.DebugEnabled { println("upstream response body:", string(responseBody)) } @@ -222,10 +237,16 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo err = common.Unmarshal(responseBody, &simpleResponse) if err != nil { + if isXaiCSAMRefusal { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry()) + } return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + if isXaiCSAMRefusal { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode, types.ErrOptionWithSkipRetry()) + } return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) } From 7eaa80583be6947c60dd5b6e9a527b8008473dbb Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 25 Jan 2026 16:38:58 +0800 Subject: [PATCH 8/8] Revert "feat: xai refusal reason" This reverts commit 2548eaf8d38108862cf6a7e05f4f2418abb07c10. --- relay/channel/openai/relay-openai.go | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index c9c71327..a4de1611 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -1,7 +1,6 @@ package openai import ( - "bytes" "fmt" "io" "net/http" @@ -23,19 +22,6 @@ import ( "github.com/gorilla/websocket" ) -const xaiCSAMSafetyCheckType = "SAFETY_CHECK_TYPE_CSAM" - -func maybeMarkXaiCSAMRefusal(c *gin.Context, info *relaycommon.RelayInfo, responseBody []byte) bool { - if c == nil || info == nil || len(responseBody) == 0 { - return false - } - if !bytes.Contains(responseBody, []byte(xaiCSAMSafetyCheckType)) { - return false - } - common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "grok_safety_check_type=csam") - return true -} - func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { if data == "" { return nil @@ -215,7 +201,6 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } - isXaiCSAMRefusal := maybeMarkXaiCSAMRefusal(c, info, responseBody) if common.DebugEnabled { println("upstream response body:", string(responseBody)) } @@ -237,16 +222,10 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo err = common.Unmarshal(responseBody, &simpleResponse) if err != nil { - if isXaiCSAMRefusal { - return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry()) - } return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { - if isXaiCSAMRefusal { - return nil, types.WithOpenAIError(*oaiError, resp.StatusCode, types.ErrOptionWithSkipRetry()) - } return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) }