From e8eea5d3ee0a39043d458252c7ed9993b14303f7 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 9 Jan 2026 18:00:40 +0800 Subject: [PATCH 01/37] fix(gemini): fetch model list via native v1beta/models endpoint Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible path when listing models for Gemini channels, improving compatibility with third-party Gemini-format providers that don't implement OpenAI routes. - Add paginated model listing with timeout and optional proxy support - Select an enabled key for multi-key Gemini channels --- controller/channel.go | 50 +++++++++++++++-- relay/channel/gemini/relay-gemini.go | 81 ++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 9fea9a80..cb97aa8c 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/service" @@ -260,11 +261,37 @@ func FetchUpstreamModels(c *gin.Context) { return } + // 对于 Gemini 渠道,使用特殊处理 + if channel.Type == constant.ChannelTypeGemini { + // 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥) + key, _, apiErr := channel.GetNextEnabledKey() + if apiErr != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()), + }) + return + } + key = strings.TrimSpace(key) + models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": models, + }) + return + } + var url string switch channel.Type { - case constant.ChannelTypeGemini: - // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY - url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader case constant.ChannelTypeAli: url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) case constant.ChannelTypeZhipu_v4: @@ -1072,6 +1099,23 @@ func FetchModels(c *gin.Context) { return } + if req.Type == constant.ChannelTypeGemini { + models, err := gemini.FetchGeminiModels(baseURL, key, "") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": models, + }) + return + } + client := &http.Client{} url := fmt.Sprintf("%s/v1/models", baseURL) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 4d93027f..1a9281d5 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1,6 +1,7 @@ package gemini import ( + "context" "encoding/json" "errors" "fmt" @@ -8,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "time" "unicode/utf8" "github.com/QuantumNous/new-api/common" @@ -1363,3 +1365,82 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. return usage, nil } + +type GeminiModelInfo struct { + Name string `json:"name"` + Version string `json:"version"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + InputTokenLimit int `json:"inputTokenLimit"` + OutputTokenLimit int `json:"outputTokenLimit"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` +} + +type GeminiModelsResponse struct { + Models []GeminiModelInfo `json:"models"` + NextPageToken string `json:"nextPageToken"` +} + +func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) { + client, err := service.GetHttpClientWithProxy(proxyURL) + if err != nil { + return nil, fmt.Errorf("创建HTTP客户端失败: %v", err) + } + + allModels := make([]string, 0) + nextPageToken := "" + maxPages := 100 // Safety limit to prevent infinite loops + + for page := 0; page < maxPages; page++ { + url := fmt.Sprintf("%s/v1beta/models", baseURL) + if nextPageToken != "" { + url = fmt.Sprintf("%s?pageToken=%s", url, nextPageToken) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + request, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + cancel() + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + request.Header.Set("x-goog-api-key", apiKey) + + response, err := client.Do(request) + if err != nil { + cancel() + return nil, fmt.Errorf("请求失败: %v", err) + } + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + cancel() + return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body)) + } + + body, err := io.ReadAll(response.Body) + response.Body.Close() + cancel() + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + var modelsResponse GeminiModelsResponse + if err = common.Unmarshal(body, &modelsResponse); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + for _, model := range modelsResponse.Models { + modelName := strings.TrimPrefix(model.Name, "models/") + allModels = append(allModels, modelName) + } + + nextPageToken = modelsResponse.NextPageToken + if nextPageToken == "" { + break + } + } + + return allModels, nil +} From db96248c5bbef0d7bd98e56cf30721631727b607 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 9 Jan 2026 18:08:11 +0800 Subject: [PATCH 02/37] =?UTF-8?q?refactor(gemini):=20=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?GeminiModelsResponse=20=E4=BB=A5=E4=BD=BF=E7=94=A8=20dto.Gemini?= =?UTF-8?q?Model=20=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/gemini/relay-gemini.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 1a9281d5..8ca9a502 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1366,18 +1366,8 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. return usage, nil } -type GeminiModelInfo struct { - Name string `json:"name"` - Version string `json:"version"` - DisplayName string `json:"displayName"` - Description string `json:"description"` - InputTokenLimit int `json:"inputTokenLimit"` - OutputTokenLimit int `json:"outputTokenLimit"` - SupportedGenerationMethods []string `json:"supportedGenerationMethods"` -} - type GeminiModelsResponse struct { - Models []GeminiModelInfo `json:"models"` + Models []dto.GeminiModel `json:"models"` NextPageToken string `json:"nextPageToken"` } @@ -1432,7 +1422,11 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) { } for _, model := range modelsResponse.Models { - modelName := strings.TrimPrefix(model.Name, "models/") + modelNameValue, ok := model.Name.(string) + if !ok { + continue + } + modelName := strings.TrimPrefix(modelNameValue, "models/") allModels = append(allModels, modelName) } From 0f0ba4adc4927e808e3b1dc3ead7237be9b55276 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 9 Jan 2026 20:37:12 +0800 Subject: [PATCH 03/37] fix: remove Minimax from FETCHABLE channels --- web/src/components/table/channels/modals/EditChannelModal.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 9e07a2bd..0abd3fdf 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -92,7 +92,7 @@ const REGION_EXAMPLE = { // 支持并且已适配通过接口获取模型列表的渠道类型 const MODEL_FETCHABLE_TYPES = new Set([ - 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43, + 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, ]); function type2secretPrompt(type) { From ffa8a4278440f13d5fb8ba6adf134fc52f167fc9 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Fri, 9 Jan 2026 20:46:47 +0800 Subject: [PATCH 04/37] =?UTF-8?q?fix(minimax):=20=E6=B7=BB=E5=8A=A0=20Mini?= =?UTF-8?q?Max-M2=20=E7=B3=BB=E5=88=97=E6=A8=A1=E5=9E=8B=E5=88=B0=20ModelL?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/minimax/constants.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go index df420d4b..91193c4b 100644 --- a/relay/channel/minimax/constants.go +++ b/relay/channel/minimax/constants.go @@ -14,6 +14,9 @@ var ModelList = []string{ "speech-02-turbo", "speech-01-hd", "speech-01-turbo", + "MiniMax-M2.1", + "MiniMax-M2.1-lightning", + "MiniMax-M2", } var ChannelName = "minimax" From c7ebd4408a2c173c9f1c45ed63f78211225ae1a0 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Fri, 9 Jan 2026 20:39:17 +0800 Subject: [PATCH 05/37] feat: add doubao video 1.5 --- dto/values.go | 55 +++++++++++++++ relay/channel/task/doubao/adaptor.go | 92 +++++++++++++++++++++----- relay/channel/task/doubao/constants.go | 1 + relay/relay_adaptor.go | 2 +- 4 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 dto/values.go diff --git a/dto/values.go b/dto/values.go new file mode 100644 index 00000000..860d5fae --- /dev/null +++ b/dto/values.go @@ -0,0 +1,55 @@ +package dto + +import ( + "encoding/json" + "strconv" +) + +type IntValue int + +func (i *IntValue) UnmarshalJSON(b []byte) error { + var n int + if err := json.Unmarshal(b, &n); err == nil { + *i = IntValue(n) + return nil + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + v, err := strconv.Atoi(s) + if err != nil { + return err + } + *i = IntValue(v) + return nil +} + +func (i IntValue) MarshalJSON() ([]byte, error) { + return json.Marshal(int(i)) +} + +type BoolValue bool + +func (b *BoolValue) UnmarshalJSON(data []byte) error { + var boolean bool + if err := json.Unmarshal(data, &boolean); err == nil { + *b = BoolValue(boolean) + return nil + } + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + if str == "true" { + *b = BoolValue(true) + } else if str == "false" { + *b = BoolValue(false) + } else { + return json.Unmarshal(data, &boolean) + } + return nil +} +func (b BoolValue) MarshalJSON() ([]byte, error) { + return json.Marshal(bool(b)) +} diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index dd21fb75..6051c7e8 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -6,6 +6,9 @@ import ( "fmt" "io" "net/http" + "time" + + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" @@ -23,18 +26,36 @@ import ( // ============================ type ContentItem struct { - Type string `json:"type"` // "text" or "image_url" - Text string `json:"text,omitempty"` // for text type - ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type + Type string `json:"type"` // "text", "image_url" or "video" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type + Video *VideoReference `json:"video,omitempty"` // for video (sample) type } type ImageURL struct { URL string `json:"url"` } +type VideoReference struct { + URL string `json:"url"` // Draft video URL +} + type requestPayload struct { - Model string `json:"model"` - Content []ContentItem `json:"content"` + Model string `json:"model"` + Content []ContentItem `json:"content"` + CallbackURL string `json:"callback_url,omitempty"` + ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + ExecutionExpiresAfter dto.IntValue `json:"execution_expires_after,omitempty"` + GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"` + Draft *dto.BoolValue `json:"draft,omitempty"` + Resolution string `json:"resolution,omitempty"` + Ratio string `json:"ratio,omitempty"` + Duration dto.IntValue `json:"duration,omitempty"` + Frames dto.IntValue `json:"frames,omitempty"` + Seed dto.IntValue `json:"seed,omitempty"` + CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` + Watermark *dto.BoolValue `json:"watermark,omitempty"` } type responsePayload struct { @@ -53,6 +74,7 @@ type responseTask struct { Duration int `json:"duration"` Ratio string `json:"ratio"` FramesPerSecond int `json:"framespersecond"` + ServiceTier string `json:"service_tier"` Usage struct { CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` @@ -98,16 +120,16 @@ func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info // BuildRequestBody converts request into Doubao specific format. func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { - v, exists := c.Get("task_request") - if !exists { - return nil, fmt.Errorf("request not found in context") + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, err } - req := v.(relaycommon.TaskSubmitReq) body, err := a.convertToRequestPayload(&req) if err != nil { return nil, errors.Wrap(err, "convert request payload failed") } + info.UpstreamModelName = body.Model data, err := json.Marshal(body) if err != nil { return nil, err @@ -141,7 +163,13 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return } - c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID}) + ov := dto.NewOpenAIVideo() + ov.ID = dResp.ID + ov.TaskID = dResp.ID + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + + c.JSON(http.StatusOK, ov) return dResp.ID, responseBody, nil } @@ -204,12 +232,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* } } - // TODO: Add support for additional parameters from metadata - // such as ratio, duration, seed, etc. - // metadata := req.Metadata - // if metadata != nil { - // // Parse and apply metadata parameters - // } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } return &r, nil } @@ -229,7 +260,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e case "pending", "queued": taskResult.Status = model.TaskStatusQueued taskResult.Progress = "10%" - case "processing": + case "processing", "running": taskResult.Status = model.TaskStatusInProgress taskResult.Progress = "50%" case "succeeded": @@ -251,3 +282,30 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e return &taskResult, nil } + +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var dResp responseTask + if err := json.Unmarshal(originTask.Data, &dResp); err != nil { + return nil, errors.Wrap(err, "unmarshal doubao task data failed") + } + + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.TaskID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + openAIVideo.SetMetadata("url", dResp.Content.VideoURL) + openAIVideo.CreatedAt = originTask.CreatedAt + openAIVideo.CompletedAt = originTask.UpdatedAt + openAIVideo.Model = originTask.Properties.OriginModelName + + if dResp.Status == "failed" { + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: "task failed", + Code: "failed", + } + } + + jsonData, _ := common.Marshal(openAIVideo) + return jsonData, nil +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go index 74b416c6..13b1b1d9 100644 --- a/relay/channel/task/doubao/constants.go +++ b/relay/channel/task/doubao/constants.go @@ -4,6 +4,7 @@ var ModelList = []string{ "doubao-seedance-1-0-pro-250528", "doubao-seedance-1-0-lite-t2v", "doubao-seedance-1-0-lite-i2v", + "doubao-seedance-1-5-pro-251215", } var ChannelName = "doubao-video" diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index b838b313..9afa3b0c 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -148,7 +148,7 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskvertex.TaskAdaptor{} case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} - case constant.ChannelTypeDoubaoVideo: + case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine: return &taskdoubao.TaskAdaptor{} case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: return &tasksora.TaskAdaptor{} From 71460cba1573e4f333ec9a48315e3fe48299428b Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:38:07 +0800 Subject: [PATCH 06/37] feat: /v1/chat/completion -> /v1/response (#2629) * feat: /v1/chat/completion -> /v1/response --- dto/openai_request.go | 4 +- dto/openai_response.go | 17 +- relay/channel/openai/chat_via_responses.go | 234 ++++++++++++++++ relay/chat_completions_via_responses.go | 160 +++++++++++ relay/compatible_handler.go | 23 ++ service/openai_chat_responses_compat.go | 18 ++ service/openai_chat_responses_mode.go | 14 + service/openaicompat/chat_to_responses.go | 262 ++++++++++++++++++ service/openaicompat/policy.go | 18 ++ service/openaicompat/regex.go | 33 +++ service/openaicompat/responses_to_chat.go | 133 +++++++++ setting/model_setting/global.go | 30 +- web/src/components/settings/ModelSetting.jsx | 11 +- web/src/i18n/locales/en.json | 5 + web/src/i18n/locales/fr.json | 5 + web/src/i18n/locales/ja.json | 5 + web/src/i18n/locales/ru.json | 5 + web/src/i18n/locales/vi.json | 5 + web/src/i18n/locales/zh.json | 5 + .../Setting/Model/SettingGlobalModel.jsx | 161 ++++++++++- 20 files changed, 1134 insertions(+), 14 deletions(-) create mode 100644 relay/channel/openai/chat_via_responses.go create mode 100644 relay/chat_completions_via_responses.go create mode 100644 service/openai_chat_responses_compat.go create mode 100644 service/openai_chat_responses_mode.go create mode 100644 service/openaicompat/chat_to_responses.go create mode 100644 service/openaicompat/policy.go create mode 100644 service/openaicompat/regex.go create mode 100644 service/openaicompat/responses_to_chat.go diff --git a/dto/openai_request.go b/dto/openai_request.go index 232a1ae1..89ebcf14 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -808,11 +808,11 @@ type OpenAIResponsesRequest struct { PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"` Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` Text json.RawMessage `json:"text,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"` Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` + TopP *float64 `json:"top_p,omitempty"` Truncation string `json:"truncation,omitempty"` User string `json:"user,omitempty"` MaxToolCalls uint `json:"max_tool_calls,omitempty"` diff --git a/dto/openai_response.go b/dto/openai_response.go index 6baee78c..16531e20 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -334,13 +334,16 @@ type IncompleteDetails struct { } type ResponsesOutput struct { - Type string `json:"type"` - ID string `json:"id"` - Status string `json:"status"` - Role string `json:"role"` - Content []ResponsesOutputContent `json:"content"` - Quality string `json:"quality"` - Size string `json:"size"` + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status"` + Role string `json:"role"` + Content []ResponsesOutputContent `json:"content"` + Quality string `json:"quality"` + Size string `json:"size"` + CallId string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` } type ResponsesOutputContent struct { diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go new file mode 100644 index 00000000..5965613c --- /dev/null +++ b/relay/channel/openai/chat_via_responses.go @@ -0,0 +1,234 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relay/helper" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func OaiResponsesToChatHandler(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("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + defer service.CloseResponseBodyGracefully(resp) + + var responsesResp dto.OpenAIResponsesResponse + const maxResponseBodyBytes = 10 << 20 // 10MB + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1)) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + if int64(len(body)) > maxResponseBodyBytes { + return nil, types.NewOpenAIError(fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if err := common.Unmarshal(body, &responsesResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + chatId := helper.GetResponseID(c) + chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if usage == nil || usage.TotalTokens == 0 { + text := service.ExtractOutputTextFromResponses(&responsesResp) + usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens()) + chatResp.Usage = *usage + } + + chatBody, err := common.Marshal(chatResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, chatBody) + return usage, nil +} + +func OaiResponsesToChatStreamHandler(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("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + defer service.CloseResponseBodyGracefully(resp) + + responseId := helper.GetResponseID(c) + createAt := time.Now().Unix() + model := info.UpstreamModelName + + var ( + usage = &dto.Usage{} + textBuilder strings.Builder + sentStart bool + sentStop bool + streamErr *types.NewAPIError + ) + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + if streamErr != nil { + return false + } + + var streamResp dto.ResponsesStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResp); err != nil { + logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error()) + return true + } + + switch streamResp.Type { + case "response.created": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + } + + case "response.output_text.delta": + if !sentStart { + if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + sentStart = true + } + + if streamResp.Delta != "" { + textBuilder.WriteString(streamResp.Delta) + delta := streamResp.Delta + chunk := &dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Content: &delta, + }, + }, + }, + } + if err := helper.ObjectData(c, chunk); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + } + + case "response.completed": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + if streamResp.Response.Usage != nil { + if streamResp.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResp.Response.Usage.InputTokens + usage.InputTokens = streamResp.Response.Usage.InputTokens + } + if streamResp.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResp.Response.Usage.OutputTokens + usage.OutputTokens = streamResp.Response.Usage.OutputTokens + } + if streamResp.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResp.Response.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if streamResp.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens + usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens + } + if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 { + usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens + } + } + } + + if !sentStart { + if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + sentStart = true + } + if !sentStop { + stop := helper.GenerateStopResponse(responseId, createAt, model, "stop") + if err := helper.ObjectData(c, stop); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + sentStop = true + } + + case "response.error", "response.failed": + if streamResp.Response != nil { + if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" { + streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError) + return false + } + } + streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + + case "response.output_item.added", "response.output_item.done": + + default: + } + + return true + }) + + if streamErr != nil { + return nil, streamErr + } + + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + } + + if !sentStart { + if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + } + if !sentStop { + stop := helper.GenerateStopResponse(responseId, createAt, model, "stop") + if err := helper.ObjectData(c, stop); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + } + if info.ShouldIncludeUsage && usage != nil { + if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage)); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + } + + helper.Done(c) + return usage, nil +} diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go new file mode 100644 index 00000000..4b369440 --- /dev/null +++ b/relay/chat_completions_via_responses.go @@ -0,0 +1,160 @@ +package relay + +import ( + "bytes" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + openaichannel "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) { + if info == nil || request == nil { + return + } + if info.ChannelSetting.SystemPrompt == "" { + return + } + + systemRole := request.GetSystemRoleName() + + containSystemPrompt := false + for _, message := range request.Messages { + if message.Role == systemRole { + containSystemPrompt = true + break + } + } + if !containSystemPrompt { + systemMessage := dto.Message{ + Role: systemRole, + Content: info.ChannelSetting.SystemPrompt, + } + request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + return + } + + if !info.ChannelSetting.SystemPromptOverride { + return + } + + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + for i, message := range request.Messages { + if message.Role != systemRole { + continue + } + if message.IsStringContent() { + request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent()) + return + } + contents := message.ParseContent() + contents = append([]dto.MediaContent{ + { + Type: dto.ContentTypeText, + Text: info.ChannelSetting.SystemPrompt, + }, + }, contents...) + request.Messages[i].Content = contents + return + } +} + +func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) { + overrideCtx := relaycommon.BuildParamOverrideContext(info) + chatJSON, err := common.Marshal(request) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if len(info.ParamOverride) > 0 { + chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + var overriddenChatReq dto.GeneralOpenAIRequest + if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil { + return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + + responsesReq, err := service.ChatCompletionsRequestToResponsesRequest(&overriddenChatReq) + if err != nil { + return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + savedRelayMode := info.RelayMode + savedRequestURLPath := info.RequestURLPath + defer func() { + info.RelayMode = savedRelayMode + info.RequestURLPath = savedRequestURLPath + }() + + info.RelayMode = relayconstant.RelayModeResponses + info.RequestURLPath = "/v1/responses" + + convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + if resp == nil { + return nil, types.NewOpenAIError(nil, types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + + if info.IsStream { + usage, newApiErr := openaichannel.OaiResponsesToChatStreamHandler(c, info, httpResp) + if newApiErr != nil { + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + return usage, nil + } + + usage, newApiErr := openaichannel.OaiResponsesToChatHandler(c, info, httpResp) + if newApiErr != nil { + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + return usage, nil +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index a536e165..d20dc93a 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" @@ -73,6 +74,28 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(info) + + if info.RelayMode == relayconstant.RelayModeChatCompletions && + !model_setting.GetGlobalSettings().PassThroughRequestEnabled && + !info.ChannelSetting.PassThroughBodyEnabled && + service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) { + applySystemPromptIfNeeded(c, info, request) + usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) + if newApiErr != nil { + return newApiErr + } + + var containAudioTokens = usage.CompletionTokenDetails.AudioTokens > 0 || usage.PromptTokensDetails.AudioTokens > 0 + var containsAudioRatios = ratio_setting.ContainsAudioRatio(info.OriginModelName) || ratio_setting.ContainsAudioCompletionRatio(info.OriginModelName) + + if containAudioTokens && containsAudioRatios { + service.PostAudioConsumeQuota(c, info, usage, "") + } else { + postConsumeQuota(c, info, usage) + } + return nil + } + var requestBody io.Reader if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { diff --git a/service/openai_chat_responses_compat.go b/service/openai_chat_responses_compat.go new file mode 100644 index 00000000..2e887386 --- /dev/null +++ b/service/openai_chat_responses_compat.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/service/openaicompat" +) + +func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) { + return openaicompat.ChatCompletionsRequestToResponsesRequest(req) +} + +func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { + return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id) +} + +func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + return openaicompat.ExtractOutputTextFromResponses(resp) +} diff --git a/service/openai_chat_responses_mode.go b/service/openai_chat_responses_mode.go new file mode 100644 index 00000000..a655a38b --- /dev/null +++ b/service/openai_chat_responses_mode.go @@ -0,0 +1,14 @@ +package service + +import ( + "github.com/QuantumNous/new-api/service/openaicompat" + "github.com/QuantumNous/new-api/setting/model_setting" +) + +func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool { + return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, model) +} + +func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool { + return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, model) +} diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go new file mode 100644 index 00000000..ddcc9f28 --- /dev/null +++ b/service/openaicompat/chat_to_responses.go @@ -0,0 +1,262 @@ +package openaicompat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +func normalizeChatImageURLToString(v any) any { + switch vv := v.(type) { + case string: + return vv + case map[string]any: + if url := common.Interface2String(vv["url"]); url != "" { + return url + } + return v + case dto.MessageImageUrl: + if vv.Url != "" { + return vv.Url + } + return v + case *dto.MessageImageUrl: + if vv != nil && vv.Url != "" { + return vv.Url + } + return v + default: + return v + } +} + +func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) { + if req == nil { + return nil, errors.New("request is nil") + } + if req.Model == "" { + return nil, errors.New("model is required") + } + if req.N > 1 { + return nil, fmt.Errorf("n>1 is not supported in responses compatibility mode") + } + + var instructionsParts []string + inputItems := make([]map[string]any, 0, len(req.Messages)) + + for _, msg := range req.Messages { + role := strings.TrimSpace(msg.Role) + if role == "" { + continue + } + + // Prefer mapping system/developer messages into `instructions`. + if role == "system" || role == "developer" { + if msg.Content == nil { + continue + } + if msg.IsStringContent() { + if s := strings.TrimSpace(msg.StringContent()); s != "" { + instructionsParts = append(instructionsParts, s) + } + continue + } + parts := msg.ParseContent() + var sb strings.Builder + for _, part := range parts { + if part.Type == dto.ContentTypeText && strings.TrimSpace(part.Text) != "" { + if sb.Len() > 0 { + sb.WriteString("\n") + } + sb.WriteString(part.Text) + } + } + if s := strings.TrimSpace(sb.String()); s != "" { + instructionsParts = append(instructionsParts, s) + } + continue + } + + item := map[string]any{ + "role": role, + } + + if msg.Content == nil { + item["content"] = "" + inputItems = append(inputItems, item) + continue + } + + if msg.IsStringContent() { + item["content"] = msg.StringContent() + inputItems = append(inputItems, item) + continue + } + + parts := msg.ParseContent() + contentParts := make([]map[string]any, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case dto.ContentTypeText: + contentParts = append(contentParts, map[string]any{ + "type": "input_text", + "text": part.Text, + }) + case dto.ContentTypeImageURL: + contentParts = append(contentParts, map[string]any{ + "type": "input_image", + "image_url": normalizeChatImageURLToString(part.ImageUrl), + }) + case dto.ContentTypeInputAudio: + contentParts = append(contentParts, map[string]any{ + "type": "input_audio", + "input_audio": part.InputAudio, + }) + case dto.ContentTypeFile: + contentParts = append(contentParts, map[string]any{ + "type": "input_file", + "file": part.File, + }) + case dto.ContentTypeVideoUrl: + contentParts = append(contentParts, map[string]any{ + "type": "input_video", + "video_url": part.VideoUrl, + }) + default: + // Best-effort: keep unknown parts as-is to avoid silently dropping context. + contentParts = append(contentParts, map[string]any{ + "type": part.Type, + }) + } + } + item["content"] = contentParts + inputItems = append(inputItems, item) + } + + inputRaw, err := common.Marshal(inputItems) + if err != nil { + return nil, err + } + + var instructionsRaw json.RawMessage + if len(instructionsParts) > 0 { + instructions := strings.Join(instructionsParts, "\n\n") + instructionsRaw, _ = common.Marshal(instructions) + } + + var toolsRaw json.RawMessage + if req.Tools != nil { + tools := make([]map[string]any, 0, len(req.Tools)) + for _, tool := range req.Tools { + switch tool.Type { + case "function": + tools = append(tools, map[string]any{ + "type": "function", + "name": tool.Function.Name, + "description": tool.Function.Description, + "parameters": tool.Function.Parameters, + }) + default: + // Best-effort: keep original tool shape for unknown types. + var m map[string]any + if b, err := common.Marshal(tool); err == nil { + _ = common.Unmarshal(b, &m) + } + if len(m) == 0 { + m = map[string]any{"type": tool.Type} + } + tools = append(tools, m) + } + } + toolsRaw, _ = common.Marshal(tools) + } + + var toolChoiceRaw json.RawMessage + if req.ToolChoice != nil { + switch v := req.ToolChoice.(type) { + case string: + toolChoiceRaw, _ = common.Marshal(v) + default: + var m map[string]any + if b, err := common.Marshal(v); err == nil { + _ = common.Unmarshal(b, &m) + } + if m == nil { + toolChoiceRaw, _ = common.Marshal(v) + } else if t, _ := m["type"].(string); t == "function" { + // Chat: {"type":"function","function":{"name":"..."}} + // Responses: {"type":"function","name":"..."} + if name, ok := m["name"].(string); ok && name != "" { + toolChoiceRaw, _ = common.Marshal(map[string]any{ + "type": "function", + "name": name, + }) + } else if fn, ok := m["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok && name != "" { + toolChoiceRaw, _ = common.Marshal(map[string]any{ + "type": "function", + "name": name, + }) + } else { + toolChoiceRaw, _ = common.Marshal(v) + } + } else { + toolChoiceRaw, _ = common.Marshal(v) + } + } else { + toolChoiceRaw, _ = common.Marshal(v) + } + } + } + + var parallelToolCallsRaw json.RawMessage + if req.ParallelTooCalls != nil { + parallelToolCallsRaw, _ = common.Marshal(*req.ParallelTooCalls) + } + + var textRaw json.RawMessage + if req.ResponseFormat != nil && req.ResponseFormat.Type != "" { + textRaw, _ = common.Marshal(map[string]any{ + "format": req.ResponseFormat, + }) + } + + maxOutputTokens := req.MaxTokens + if req.MaxCompletionTokens > maxOutputTokens { + maxOutputTokens = req.MaxCompletionTokens + } + + var topP *float64 + if req.TopP != 0 { + topP = common.GetPointer(req.TopP) + } + + out := &dto.OpenAIResponsesRequest{ + Model: req.Model, + Input: inputRaw, + Instructions: instructionsRaw, + MaxOutputTokens: maxOutputTokens, + Stream: req.Stream, + Temperature: req.Temperature, + Text: textRaw, + ToolChoice: toolChoiceRaw, + Tools: toolsRaw, + TopP: topP, + User: req.User, + ParallelToolCalls: parallelToolCallsRaw, + Store: req.Store, + Metadata: req.Metadata, + } + + if req.ReasoningEffort != "" && req.ReasoningEffort != "none" { + out.Reasoning = &dto.Reasoning{ + Effort: req.ReasoningEffort, + } + } + + return out, nil +} diff --git a/service/openaicompat/policy.go b/service/openaicompat/policy.go new file mode 100644 index 00000000..39b11ce5 --- /dev/null +++ b/service/openaicompat/policy.go @@ -0,0 +1,18 @@ +package openaicompat + +import "github.com/QuantumNous/new-api/setting/model_setting" + +func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool { + if !policy.IsChannelEnabled(channelID) { + return false + } + return matchAnyRegex(policy.ModelPatterns, model) +} + +func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool { + return ShouldChatCompletionsUseResponsesPolicy( + model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy, + channelID, + model, + ) +} diff --git a/service/openaicompat/regex.go b/service/openaicompat/regex.go new file mode 100644 index 00000000..4ad5e929 --- /dev/null +++ b/service/openaicompat/regex.go @@ -0,0 +1,33 @@ +package openaicompat + +import ( + "regexp" + "sync" +) + +var compiledRegexCache sync.Map // map[string]*regexp.Regexp + +func matchAnyRegex(patterns []string, s string) bool { + if len(patterns) == 0 || s == "" { + return false + } + for _, pattern := range patterns { + if pattern == "" { + continue + } + re, ok := compiledRegexCache.Load(pattern) + if !ok { + compiled, err := regexp.Compile(pattern) + if err != nil { + // Treat invalid patterns as non-matching to avoid breaking runtime traffic. + continue + } + re = compiled + compiledRegexCache.Store(pattern, re) + } + if re.(*regexp.Regexp).MatchString(s) { + return true + } + } + return false +} diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go new file mode 100644 index 00000000..abd03592 --- /dev/null +++ b/service/openaicompat/responses_to_chat.go @@ -0,0 +1,133 @@ +package openaicompat + +import ( + "errors" + "strings" + + "github.com/QuantumNous/new-api/dto" +) + +func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { + if resp == nil { + return nil, nil, errors.New("response is nil") + } + + text := ExtractOutputTextFromResponses(resp) + + usage := &dto.Usage{} + if resp.Usage != nil { + if resp.Usage.InputTokens != 0 { + usage.PromptTokens = resp.Usage.InputTokens + usage.InputTokens = resp.Usage.InputTokens + } + if resp.Usage.OutputTokens != 0 { + usage.CompletionTokens = resp.Usage.OutputTokens + usage.OutputTokens = resp.Usage.OutputTokens + } + if resp.Usage.TotalTokens != 0 { + usage.TotalTokens = resp.Usage.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if resp.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens + usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens + } + if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 { + usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens + } + } + + created := resp.CreatedAt + + var toolCalls []dto.ToolCallResponse + if text == "" && len(resp.Output) > 0 { + for _, out := range resp.Output { + if out.Type != "function_call" { + continue + } + name := strings.TrimSpace(out.Name) + if name == "" { + continue + } + callId := strings.TrimSpace(out.CallId) + if callId == "" { + callId = strings.TrimSpace(out.ID) + } + toolCalls = append(toolCalls, dto.ToolCallResponse{ + ID: callId, + Type: "function", + Function: dto.FunctionResponse{ + Name: name, + Arguments: out.Arguments, + }, + }) + } + } + + finishReason := "stop" + if len(toolCalls) > 0 { + finishReason = "tool_calls" + } + + msg := dto.Message{ + Role: "assistant", + Content: text, + } + if len(toolCalls) > 0 { + msg.SetToolCalls(toolCalls) + msg.Content = "" + } + + out := &dto.OpenAITextResponse{ + Id: id, + Object: "chat.completion", + Created: created, + Model: resp.Model, + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: msg, + FinishReason: finishReason, + }, + }, + Usage: *usage, + } + + return out, usage, nil +} + +func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || len(resp.Output) == 0 { + return "" + } + + var sb strings.Builder + + // Prefer assistant message outputs. + for _, out := range resp.Output { + if out.Type != "message" { + continue + } + if out.Role != "" && out.Role != "assistant" { + continue + } + for _, c := range out.Content { + if c.Type == "output_text" && c.Text != "" { + sb.WriteString(c.Text) + } + } + } + if sb.Len() > 0 { + return sb.String() + } + for _, out := range resp.Output { + for _, c := range out.Content { + if c.Text != "" { + sb.WriteString(c.Text) + } + } + } + return sb.String() +} diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go index f51ebc89..58017117 100644 --- a/setting/model_setting/global.go +++ b/setting/model_setting/global.go @@ -1,14 +1,36 @@ package model_setting import ( + "slices" "strings" "github.com/QuantumNous/new-api/setting/config" ) +type ChatCompletionsToResponsesPolicy struct { + Enabled bool `json:"enabled"` + AllChannels bool `json:"all_channels"` + ChannelIDs []int `json:"channel_ids,omitempty"` + ModelPatterns []string `json:"model_patterns,omitempty"` +} + +func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int) bool { + if !p.Enabled { + return false + } + if p.AllChannels { + return true + } + if channelID == 0 || len(p.ChannelIDs) == 0 { + return false + } + return slices.Contains(p.ChannelIDs, channelID) +} + type GlobalSettings struct { - PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` - ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` + PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` + ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` + ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` } // 默认配置 @@ -18,6 +40,10 @@ var defaultOpenaiSettings = GlobalSettings{ "moonshotai/kimi-k2-thinking", "kimi-k2-thinking", }, + ChatCompletionsToResponsesPolicy: ChatCompletionsToResponsesPolicy{ + Enabled: false, + AllChannels: true, + }, } // 全局实例 diff --git a/web/src/components/settings/ModelSetting.jsx b/web/src/components/settings/ModelSetting.jsx index d498a321..1f220669 100644 --- a/web/src/components/settings/ModelSetting.jsx +++ b/web/src/components/settings/ModelSetting.jsx @@ -39,6 +39,7 @@ const ModelSetting = () => { 'claude.thinking_adapter_budget_tokens_percentage': 0.8, 'global.pass_through_request_enabled': false, 'global.thinking_model_blacklist': '[]', + 'global.chat_completions_to_responses_policy': '{}', 'general_setting.ping_interval_enabled': false, 'general_setting.ping_interval_seconds': 60, 'gemini.thinking_adapter_enabled': false, @@ -59,10 +60,16 @@ const ModelSetting = () => { item.key === 'claude.model_headers_settings' || item.key === 'claude.default_max_tokens' || item.key === 'gemini.supported_imagine_models' || - item.key === 'global.thinking_model_blacklist' + item.key === 'global.thinking_model_blacklist' || + item.key === 'global.chat_completions_to_responses_policy' ) { if (item.value !== '') { - item.value = JSON.stringify(JSON.parse(item.value), null, 2); + try { + item.value = JSON.stringify(JSON.parse(item.value), null, 2); + } catch (e) { + // Keep raw value so user can fix it, and avoid crashing the page. + console.error(`Invalid JSON for option ${item.key}:`, e); + } } } // Keep boolean config keys ending with enabled/Enabled so UI parses correctly. diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index df6fb8fe..f36f70e9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2583,6 +2583,11 @@ "签到最大额度": "Maximum check-in quota", "签到奖励的最大额度": "Maximum quota for check-in rewards", "保存签到设置": "Save check-in settings", + "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses Compatibility (Beta)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Notice: This feature is beta. The configuration structure and behavior may change in the future. Do not use in production.", + "填充模板(指定渠道)": "Fill template (selected channels)", + "填充模板(全渠道)": "Fill template (all channels)", + "格式化 JSON": "Format JSON", "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Notice: This configuration only affects how models are displayed in the Model Marketplace and does not impact actual model invocation or routing. To configure real invocation behavior, please go to Channel Management.", "确认关闭提示": "Confirm close", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "After closing, this notice will no longer be shown (only for this browser). Are you sure you want to close it?", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 7f611605..762a92d2 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2597,6 +2597,11 @@ "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Remarque : cette configuration n'affecte que l'affichage des modèles dans la place de marché des modèles et n'a aucun impact sur l'invocation ou le routage réels. Pour configurer le comportement réel des appels, veuillez aller dans « Gestion des canaux ».", "确认关闭提示": "Confirmer la fermeture", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Après fermeture, cet avertissement ne sera plus affiché (uniquement pour ce navigateur). Voulez-vous vraiment le fermer ?", + "ChatCompletions→Responses 兼容配置(Beta)": "Compatibilité ChatCompletions→Responses (bêta)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Remarque : cette fonctionnalité est en version bêta. La structure de configuration et le comportement peuvent changer à l’avenir. Ne l’utilisez pas en production.", + "填充模板(指定渠道)": "Remplir le modèle (canaux sélectionnés)", + "填充模板(全渠道)": "Remplir le modèle (tous les canaux)", + "格式化 JSON": "Formater le JSON", "关闭提示": "Fermer l’avertissement", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Remarque : les tests sur cette page utilisent des requêtes non-streaming. Si un canal ne prend en charge que les réponses en streaming, les tests peuvent échouer. Veuillez vous référer à l’usage réel.", "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Remarque : la correspondance des endpoints sert uniquement à l’affichage dans la place de marché des modèles et n’affecte pas l’invocation réelle. Pour configurer l’invocation réelle, veuillez aller dans « Gestion des canaux »." diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 989165e8..d2afc06c 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2580,6 +2580,11 @@ "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "注意: ここでの設定は「モデル広場」での表示にのみ影響し、実際の呼び出しやルーティングには影響しません。実際の呼び出しを設定する場合は、「チャネル管理」で設定してください。", "确认关闭提示": "閉じる確認", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "閉じると、このお知らせは今後表示されません(このブラウザのみ)。閉じてもよろしいですか?", + "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 互換設定(ベータ)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "注意: この機能はベータ版です。今後、設定構造や挙動が変更される可能性があります。本番環境では使用しないでください。", + "填充模板(指定渠道)": "テンプレートを入力(指定チャネル)", + "填充模板(全渠道)": "テンプレートを入力(全チャネル)", + "格式化 JSON": "JSON を整形", "关闭提示": "お知らせを閉じる", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "注意: このページのテストは非ストリーミングリクエストです。チャネルがストリーミング応答のみ対応の場合、テストが失敗することがあります。実際の利用結果を優先してください。", "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。" diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 5cb5c6e0..0298db55 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2610,6 +2610,11 @@ "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Примечание: эта настройка влияет только на отображение моделей в «Маркетплейсе моделей» и не влияет на фактический вызов или маршрутизацию. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».", "确认关闭提示": "Подтвердить закрытие", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "После закрытия это уведомление больше не будет показываться (только в этом браузере). Закрыть?", + "ChatCompletions→Responses 兼容配置(Beta)": "Совместимость ChatCompletions→Responses (бета)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Примечание: это бета-функция. Структура конфигурации и поведение могут измениться в будущем. Не используйте в продакшене.", + "填充模板(指定渠道)": "Заполнить шаблон (выбранные каналы)", + "填充模板(全渠道)": "Заполнить шаблон (все каналы)", + "格式化 JSON": "Форматировать JSON", "关闭提示": "Закрыть уведомление", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.", "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами»." diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 52f6d835..b2527f69 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3160,6 +3160,11 @@ "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Lưu ý: Cấu hình tại đây chỉ ảnh hưởng đến cách hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi hoặc định tuyến thực tế. Nếu cần cấu hình hành vi gọi thực tế, vui lòng thiết lập trong \"Quản lý kênh\".", "确认关闭提示": "Xác nhận đóng", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "Sau khi đóng, thông báo này sẽ không còn hiển thị nữa (chỉ với trình duyệt này). Bạn có chắc muốn đóng không?", + "ChatCompletions→Responses 兼容配置(Beta)": "Tương thích ChatCompletions→Responses (Beta)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Lưu ý: Đây là tính năng beta. Cấu trúc cấu hình và hành vi có thể thay đổi trong tương lai. Không dùng trong môi trường production.", + "填充模板(指定渠道)": "Điền mẫu (kênh được chọn)", + "填充模板(全渠道)": "Điền mẫu (tất cả kênh)", + "格式化 JSON": "Định dạng JSON", "关闭提示": "Đóng thông báo", "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Lưu ý: Bài kiểm tra trên trang này sử dụng yêu cầu không streaming. Nếu kênh chỉ hỗ trợ phản hồi streaming, bài kiểm tra có thể thất bại. Vui lòng dựa vào sử dụng thực tế.", "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Lưu ý: Ánh xạ endpoint chỉ dùng để hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi thực tế. Để cấu hình gọi thực tế, vui lòng vào \"Quản lý kênh\"." diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 56a9a890..3b67fba1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2569,6 +2569,11 @@ "签到最大额度": "签到最大额度", "签到奖励的最大额度": "签到奖励的最大额度", "保存签到设置": "保存签到设置", + "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)", + "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。", + "填充模板(指定渠道)": "填充模板(指定渠道)", + "填充模板(全渠道)": "填充模板(全渠道)", + "格式化 JSON": "格式化 JSON", "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。", "确认关闭提示": "确认关闭提示", "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?", diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx index 2ed9d1bc..197f297b 100644 --- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx +++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin, Banner } from '@douyinfe/semi-ui'; +import { Button, Col, Form, Row, Spin, Banner, Tag } from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -35,9 +35,31 @@ const thinkingExample = JSON.stringify( 2, ); +const chatCompletionsToResponsesPolicyExample = JSON.stringify( + { + enabled: true, + all_channels: false, + channel_ids: [1, 2], + model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'], + }, + null, + 2, +); + +const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify( + { + enabled: true, + all_channels: true, + model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'], + }, + null, + 2, +); + const defaultGlobalSettingInputs = { 'global.pass_through_request_enabled': false, 'global.thinking_model_blacklist': '[]', + 'global.chat_completions_to_responses_policy': '{}', 'general_setting.ping_interval_enabled': false, 'general_setting.ping_interval_seconds': 60, }; @@ -55,6 +77,10 @@ export default function SettingGlobalModel(props) { const text = typeof value === 'string' ? value.trim() : ''; return text === '' ? '[]' : value; } + if (key === 'global.chat_completions_to_responses_policy') { + const text = typeof value === 'string' ? value.trim() : ''; + return text === '' ? '{}' : value; + } return value; }; @@ -108,6 +134,16 @@ export default function SettingGlobalModel(props) { value = defaultGlobalSettingInputs[key]; } } + if (key === 'global.chat_completions_to_responses_policy') { + try { + value = + value && String(value).trim() !== '' + ? JSON.stringify(JSON.parse(value), null, 2) + : defaultGlobalSettingInputs[key]; + } catch (error) { + value = defaultGlobalSettingInputs[key]; + } + } currentInputs[key] = value; } else { currentInputs[key] = defaultGlobalSettingInputs[key]; @@ -180,6 +216,129 @@ export default function SettingGlobalModel(props) { + + + + + {t('ChatCompletions→Responses 兼容配置')}{' '} + + Alpha + + + } + description={t( + '提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。', + )} + /> + + + + + +
+ + + +
+ +
+ + + + { + if (!value || value.trim() === '') return true; + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + extraText={t( + '当客户端调用 /v1/chat/completions 且 model 命中 model_patterns 时,自动改走上游 /v1/responses,并把响应转换回 /v1/chat/completions 结构', + )} + onChange={(value) => + setInputs({ + ...inputs, + 'global.chat_completions_to_responses_policy': value, + }) + } + /> + + +
+ From 14b3dac82ccfcfd569a3dd134ae2b82b09a503d4 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sun, 11 Jan 2026 23:34:18 +0800 Subject: [PATCH 07/37] fix: clean propertyNames for gemini function --- relay/channel/gemini/relay-gemini.go | 1 + 1 file changed, 1 insertion(+) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 8ca9a502..65e5aa98 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -675,6 +675,7 @@ func cleanFunctionParameters(params interface{}) interface{} { delete(cleanedMap, "exclusiveMinimum") delete(cleanedMap, "$schema") delete(cleanedMap, "additionalProperties") + delete(cleanedMap, "propertyNames") // Check and clean 'format' for string types if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { From 5568423e5bebb68c5ef5f104c0f42694fb76464e Mon Sep 17 00:00:00 2001 From: dean <1006393151@qq.com> Date: Mon, 12 Jan 2026 12:23:24 +0800 Subject: [PATCH 08/37] fix: support snake_case fields in GeminiChatGenerationConfig --- dto/gemini.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/dto/gemini.go b/dto/gemini.go index 7c5969ef..17881c52 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -341,6 +341,88 @@ type GeminiChatGenerationConfig struct { ImageConfig json.RawMessage `json:"imageConfig,omitempty"` // RawMessage to allow flexible image config } +// UnmarshalJSON allows GeminiChatGenerationConfig to accept both snake_case and camelCase fields. +func (c *GeminiChatGenerationConfig) UnmarshalJSON(data []byte) error { + type Alias GeminiChatGenerationConfig + var aux struct { + Alias + TopPSnake float64 `json:"top_p,omitempty"` + TopKSnake float64 `json:"top_k,omitempty"` + MaxOutputTokensSnake uint `json:"max_output_tokens,omitempty"` + CandidateCountSnake int `json:"candidate_count,omitempty"` + StopSequencesSnake []string `json:"stop_sequences,omitempty"` + ResponseMimeTypeSnake string `json:"response_mime_type,omitempty"` + ResponseSchemaSnake any `json:"response_schema,omitempty"` + ResponseJsonSchemaSnake json.RawMessage `json:"response_json_schema,omitempty"` + PresencePenaltySnake *float32 `json:"presence_penalty,omitempty"` + FrequencyPenaltySnake *float32 `json:"frequency_penalty,omitempty"` + ResponseLogprobsSnake bool `json:"response_logprobs,omitempty"` + MediaResolutionSnake MediaResolution `json:"media_resolution,omitempty"` + ResponseModalitiesSnake []string `json:"response_modalities,omitempty"` + ThinkingConfigSnake *GeminiThinkingConfig `json:"thinking_config,omitempty"` + SpeechConfigSnake json.RawMessage `json:"speech_config,omitempty"` + ImageConfigSnake json.RawMessage `json:"image_config,omitempty"` + } + + if err := common.Unmarshal(data, &aux); err != nil { + return err + } + + *c = GeminiChatGenerationConfig(aux.Alias) + + // Prioritize snake_case if present + if aux.TopPSnake != 0 { + c.TopP = aux.TopPSnake + } + if aux.TopKSnake != 0 { + c.TopK = aux.TopKSnake + } + if aux.MaxOutputTokensSnake != 0 { + c.MaxOutputTokens = aux.MaxOutputTokensSnake + } + if aux.CandidateCountSnake != 0 { + c.CandidateCount = aux.CandidateCountSnake + } + if len(aux.StopSequencesSnake) > 0 { + c.StopSequences = aux.StopSequencesSnake + } + if aux.ResponseMimeTypeSnake != "" { + c.ResponseMimeType = aux.ResponseMimeTypeSnake + } + if aux.ResponseSchemaSnake != nil { + c.ResponseSchema = aux.ResponseSchemaSnake + } + if len(aux.ResponseJsonSchemaSnake) > 0 { + c.ResponseJsonSchema = aux.ResponseJsonSchemaSnake + } + if aux.PresencePenaltySnake != nil { + c.PresencePenalty = aux.PresencePenaltySnake + } + if aux.FrequencyPenaltySnake != nil { + c.FrequencyPenalty = aux.FrequencyPenaltySnake + } + if aux.ResponseLogprobsSnake { + c.ResponseLogprobs = aux.ResponseLogprobsSnake + } + if aux.MediaResolutionSnake != "" { + c.MediaResolution = aux.MediaResolutionSnake + } + if len(aux.ResponseModalitiesSnake) > 0 { + c.ResponseModalities = aux.ResponseModalitiesSnake + } + if aux.ThinkingConfigSnake != nil { + c.ThinkingConfig = aux.ThinkingConfigSnake + } + if len(aux.SpeechConfigSnake) > 0 { + c.SpeechConfig = aux.SpeechConfigSnake + } + if len(aux.ImageConfigSnake) > 0 { + c.ImageConfig = aux.ImageConfigSnake + } + + return nil +} + type MediaResolution string type GeminiChatCandidate struct { From ac04c802a7268deb97d8284c200acdaa3cb99525 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:47:45 +0800 Subject: [PATCH 09/37] Merge pull request #2647 from seefs001/feature/status-code-auto-disable feat: status code auto-disable configuration --- controller/option.go | 10 ++ controller/relay.go | 4 +- model/option.go | 3 + service/channel.go | 5 +- .../operation_setting/status_code_ranges.go | 147 ++++++++++++++++++ .../status_code_ranges_test.go | 52 +++++++ types/error.go | 28 ++++ .../components/settings/OperationSetting.jsx | 1 + web/src/helpers/index.js | 1 + web/src/helpers/statusCodeRules.js | 96 ++++++++++++ web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/zh.json | 4 + .../Setting/Operation/SettingsMonitoring.jsx | 69 +++++++- 13 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 setting/operation_setting/status_code_ranges.go create mode 100644 setting/operation_setting/status_code_ranges_test.go create mode 100644 web/src/helpers/statusCodeRules.js diff --git a/controller/option.go b/controller/option.go index 4d5b4e8d..a2db9532 100644 --- a/controller/option.go +++ b/controller/option.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/console_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/system_setting" @@ -177,6 +178,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "AutomaticDisableStatusCodes": + _, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "console_setting.api_info": err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") if err != nil { diff --git a/controller/relay.go b/controller/relay.go index 9759fa30..72ea3e24 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -348,7 +348,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan { gopool.Go(func() { - service.DisableChannel(channelError, err.Error()) + service.DisableChannel(channelError, err.ErrorWithStatusCode()) }) } @@ -378,7 +378,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) } other["admin_info"] = adminInfo - model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) + model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other) } } diff --git a/model/option.go b/model/option.go index e9fd50d7..24cf7862 100644 --- a/model/option.go +++ b/model/option.go @@ -143,6 +143,7 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 @@ -444,6 +445,8 @@ func updateOptionMap(key string, value string) (err error) { setting.SensitiveWordsFromString(value) case "AutomaticDisableKeywords": operation_setting.AutomaticDisableKeywordsFromString(value) + case "AutomaticDisableStatusCodes": + err = operation_setting.AutomaticDisableStatusCodesFromString(value) case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": diff --git a/service/channel.go b/service/channel.go index 8f8a3572..96bc1efe 100644 --- a/service/channel.go +++ b/service/channel.go @@ -57,9 +57,12 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool { if types.IsSkipRetryError(err) { return false } - if err.StatusCode == http.StatusUnauthorized { + if operation_setting.ShouldDisableByStatusCode(err.StatusCode) { return true } + //if err.StatusCode == http.StatusUnauthorized { + // return true + //} if err.StatusCode == http.StatusForbidden { switch channelType { case constant.ChannelTypeGemini: diff --git a/setting/operation_setting/status_code_ranges.go b/setting/operation_setting/status_code_ranges.go new file mode 100644 index 00000000..7a763008 --- /dev/null +++ b/setting/operation_setting/status_code_ranges.go @@ -0,0 +1,147 @@ +package operation_setting + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +type StatusCodeRange struct { + Start int + End int +} + +var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}} + +func AutomaticDisableStatusCodesToString() string { + if len(AutomaticDisableStatusCodeRanges) == 0 { + return "" + } + parts := make([]string, 0, len(AutomaticDisableStatusCodeRanges)) + for _, r := range AutomaticDisableStatusCodeRanges { + if r.Start == r.End { + parts = append(parts, strconv.Itoa(r.Start)) + continue + } + parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End)) + } + return strings.Join(parts, ",") +} + +func AutomaticDisableStatusCodesFromString(s string) error { + ranges, err := ParseHTTPStatusCodeRanges(s) + if err != nil { + return err + } + AutomaticDisableStatusCodeRanges = ranges + return nil +} + +func ShouldDisableByStatusCode(code int) bool { + if code < 100 || code > 599 { + return false + } + for _, r := range AutomaticDisableStatusCodeRanges { + if code < r.Start { + return false + } + if code <= r.End { + return true + } + } + return false +} + +func ParseHTTPStatusCodeRanges(input string) ([]StatusCodeRange, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, nil + } + + input = strings.NewReplacer(",", ",").Replace(input) + segments := strings.Split(input, ",") + + var ranges []StatusCodeRange + var invalid []string + + for _, seg := range segments { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + r, err := parseHTTPStatusCodeToken(seg) + if err != nil { + invalid = append(invalid, seg) + continue + } + ranges = append(ranges, r) + } + + if len(invalid) > 0 { + return nil, fmt.Errorf("invalid http status code rules: %s", strings.Join(invalid, ", ")) + } + if len(ranges) == 0 { + return nil, nil + } + + sort.Slice(ranges, func(i, j int) bool { + if ranges[i].Start == ranges[j].Start { + return ranges[i].End < ranges[j].End + } + return ranges[i].Start < ranges[j].Start + }) + + merged := []StatusCodeRange{ranges[0]} + for _, r := range ranges[1:] { + last := &merged[len(merged)-1] + if r.Start <= last.End+1 { + if r.End > last.End { + last.End = r.End + } + continue + } + merged = append(merged, r) + } + + return merged, nil +} + +func parseHTTPStatusCodeToken(token string) (StatusCodeRange, error) { + token = strings.TrimSpace(token) + token = strings.ReplaceAll(token, " ", "") + if token == "" { + return StatusCodeRange{}, fmt.Errorf("empty token") + } + + if strings.Contains(token, "-") { + parts := strings.Split(token, "-") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return StatusCodeRange{}, fmt.Errorf("invalid range token: %s", token) + } + start, err := strconv.Atoi(parts[0]) + if err != nil { + return StatusCodeRange{}, fmt.Errorf("invalid range start: %s", token) + } + end, err := strconv.Atoi(parts[1]) + if err != nil { + return StatusCodeRange{}, fmt.Errorf("invalid range end: %s", token) + } + if start > end { + return StatusCodeRange{}, fmt.Errorf("range start > end: %s", token) + } + if start < 100 || end > 599 { + return StatusCodeRange{}, fmt.Errorf("range out of bounds: %s", token) + } + return StatusCodeRange{Start: start, End: end}, nil + } + + code, err := strconv.Atoi(token) + if err != nil { + return StatusCodeRange{}, fmt.Errorf("invalid status code: %s", token) + } + if code < 100 || code > 599 { + return StatusCodeRange{}, fmt.Errorf("status code out of bounds: %s", token) + } + return StatusCodeRange{Start: code, End: code}, nil +} diff --git a/setting/operation_setting/status_code_ranges_test.go b/setting/operation_setting/status_code_ranges_test.go new file mode 100644 index 00000000..1712efd7 --- /dev/null +++ b/setting/operation_setting/status_code_ranges_test.go @@ -0,0 +1,52 @@ +package operation_setting + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseHTTPStatusCodeRanges_CommaSeparated(t *testing.T) { + ranges, err := ParseHTTPStatusCodeRanges("401,403,500-599") + require.NoError(t, err) + require.Equal(t, []StatusCodeRange{ + {Start: 401, End: 401}, + {Start: 403, End: 403}, + {Start: 500, End: 599}, + }, ranges) +} + +func TestParseHTTPStatusCodeRanges_MergeAndNormalize(t *testing.T) { + ranges, err := ParseHTTPStatusCodeRanges("500-505,504,401,403,402") + require.NoError(t, err) + require.Equal(t, []StatusCodeRange{ + {Start: 401, End: 403}, + {Start: 500, End: 505}, + }, ranges) +} + +func TestParseHTTPStatusCodeRanges_Invalid(t *testing.T) { + _, err := ParseHTTPStatusCodeRanges("99,600,foo,500-400,500-") + require.Error(t, err) +} + +func TestParseHTTPStatusCodeRanges_NoComma_IsInvalid(t *testing.T) { + _, err := ParseHTTPStatusCodeRanges("401 403") + require.Error(t, err) +} + +func TestShouldDisableByStatusCode(t *testing.T) { + orig := AutomaticDisableStatusCodeRanges + t.Cleanup(func() { AutomaticDisableStatusCodeRanges = orig }) + + AutomaticDisableStatusCodeRanges = []StatusCodeRange{ + {Start: 401, End: 403}, + {Start: 500, End: 599}, + } + + require.True(t, ShouldDisableByStatusCode(401)) + require.True(t, ShouldDisableByStatusCode(403)) + require.False(t, ShouldDisableByStatusCode(404)) + require.True(t, ShouldDisableByStatusCode(500)) + require.False(t, ShouldDisableByStatusCode(200)) +} diff --git a/types/error.go b/types/error.go index b060a9db..e112eeef 100644 --- a/types/error.go +++ b/types/error.go @@ -130,6 +130,20 @@ func (e *NewAPIError) Error() string { return e.Err.Error() } +func (e *NewAPIError) ErrorWithStatusCode() string { + if e == nil { + return "" + } + msg := e.Error() + if e.StatusCode == 0 { + return msg + } + if msg == "" { + return fmt.Sprintf("status_code=%d", e.StatusCode) + } + return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg) +} + func (e *NewAPIError) MaskSensitiveError() string { if e == nil { return "" @@ -144,6 +158,20 @@ func (e *NewAPIError) MaskSensitiveError() string { return common.MaskSensitiveInfo(errStr) } +func (e *NewAPIError) MaskSensitiveErrorWithStatusCode() string { + if e == nil { + return "" + } + msg := e.MaskSensitiveError() + if e.StatusCode == 0 { + return msg + } + if msg == "" { + return fmt.Sprintf("status_code=%d", e.StatusCode) + } + return fmt.Sprintf("status_code=%d, %s", e.StatusCode, msg) +} + func (e *NewAPIError) SetMessage(message string) { e.Err = errors.New(message) } diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 92591db4..4a77bcf1 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -70,6 +70,7 @@ const OperationSetting = () => { AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', + AutomaticDisableStatusCodes: '401', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, 'checkin_setting.enabled': false, diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js index cfb0270e..a86c3bca 100644 --- a/web/src/helpers/index.js +++ b/web/src/helpers/index.js @@ -29,3 +29,4 @@ export * from './token'; export * from './boolean'; export * from './dashboard'; export * from './passkey'; +export * from './statusCodeRules'; diff --git a/web/src/helpers/statusCodeRules.js b/web/src/helpers/statusCodeRules.js new file mode 100644 index 00000000..a0d5e75f --- /dev/null +++ b/web/src/helpers/statusCodeRules.js @@ -0,0 +1,96 @@ +export function parseHttpStatusCodeRules(input) { + const raw = (input ?? '').toString().trim(); + if (raw.length === 0) { + return { + ok: true, + ranges: [], + tokens: [], + normalized: '', + invalidTokens: [], + }; + } + + const sanitized = raw.replace(/[,]/g, ','); + const segments = sanitized.split(/[,]/g); + + const ranges = []; + const invalidTokens = []; + + for (const segment of segments) { + const trimmed = segment.trim(); + if (!trimmed) continue; + const parsed = parseToken(trimmed); + if (!parsed) invalidTokens.push(trimmed); + else ranges.push(parsed); + } + + if (invalidTokens.length > 0) { + return { + ok: false, + ranges: [], + tokens: [], + normalized: raw, + invalidTokens, + }; + } + + const merged = mergeRanges(ranges); + const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)); + const normalized = tokens.join(','); + + return { + ok: true, + ranges: merged, + tokens, + normalized, + invalidTokens: [], + }; +} + +function parseToken(token) { + const cleaned = (token ?? '').toString().trim().replaceAll(' ', ''); + if (!cleaned) return null; + + if (cleaned.includes('-')) { + const parts = cleaned.split('-'); + if (parts.length !== 2) return null; + const [a, b] = parts; + if (!isNumber(a) || !isNumber(b)) return null; + const start = Number.parseInt(a, 10); + const end = Number.parseInt(b, 10); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + if (start > end) return null; + if (start < 100 || end > 599) return null; + return { start, end }; + } + + if (!isNumber(cleaned)) return null; + const code = Number.parseInt(cleaned, 10); + if (!Number.isFinite(code)) return null; + if (code < 100 || code > 599) return null; + return { start: code, end: code }; +} + +function isNumber(s) { + return typeof s === 'string' && /^\d+$/.test(s); +} + +function mergeRanges(ranges) { + if (!Array.isArray(ranges) || ranges.length === 0) return []; + + const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end)); + const merged = [sorted[0]]; + + for (let i = 1; i < sorted.length; i += 1) { + const current = sorted[i]; + const last = merged[merged.length - 1]; + + if (current.start <= last.end + 1) { + last.end = Math.max(last.end, current.end); + continue; + } + merged.push({ ...current }); + } + + return merged; +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f36f70e9..f6d55544 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1923,6 +1923,10 @@ "自动测试所有通道间隔时间": "Auto test interval for all channels", "自动禁用": "Auto disabled", "自动禁用关键词": "Automatic disable keywords", + "自动禁用状态码": "Auto-disable status codes", + "自动禁用状态码格式不正确": "Invalid auto-disable status code format", + "支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas", + "例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599", "自动选择": "Auto Select", "自定义充值数量选项": "Custom Recharge Amount Options", "自定义充值数量选项不是合法的 JSON 数组": "Custom recharge amount options is not a valid JSON array", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3b67fba1..e91f50a4 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1909,6 +1909,10 @@ "自动测试所有通道间隔时间": "自动测试所有通道间隔时间", "自动禁用": "自动禁用", "自动禁用关键词": "自动禁用关键词", + "自动禁用状态码": "自动禁用状态码", + "自动禁用状态码格式不正确": "自动禁用状态码格式不正确", + "支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔", + "例如:401, 403, 429, 500-599": "例如:401,403,429,500-599", "自动选择": "自动选择", "自定义充值数量选项": "自定义充值数量选项", "自定义充值数量选项不是合法的 JSON 数组": "自定义充值数量选项不是合法的 JSON 数组", diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index b93a5ff0..9715ef3c 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -18,19 +18,29 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui'; +import { + Button, + Col, + Form, + Row, + Spin, + Tag, + Typography, +} from '@douyinfe/semi-ui'; import { compareObjects, API, showError, showSuccess, showWarning, + parseHttpStatusCodeRules, verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; export default function SettingsMonitoring(props) { const { t } = useTranslation(); + const { Text } = Typography; const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState({ ChannelDisableThreshold: '', @@ -38,21 +48,37 @@ export default function SettingsMonitoring(props) { AutomaticDisableChannelEnabled: false, AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', + AutomaticDisableStatusCodes: '401', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10, }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); + const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules( + inputs.AutomaticDisableStatusCodes || '', + ); function onSubmit() { const updateArray = compareObjects(inputs, inputsRow); if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); + if (!parsedAutoDisableStatusCodes.ok) { + const details = + parsedAutoDisableStatusCodes.invalidTokens && + parsedAutoDisableStatusCodes.invalidTokens.length > 0 + ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join(', ')}` + : ''; + return showError(`${t('自动禁用状态码格式不正确')}${details}`); + } const requestQueue = updateArray.map((item) => { let value = ''; if (typeof inputs[item.key] === 'boolean') { value = String(inputs[item.key]); } else { - value = inputs[item.key]; + if (item.key === 'AutomaticDisableStatusCodes') { + value = parsedAutoDisableStatusCodes.normalized; + } else { + value = inputs[item.key]; + } } return API.put('/api/option/', { key: item.key, @@ -207,6 +233,45 @@ export default function SettingsMonitoring(props) { + + setInputs({ ...inputs, AutomaticDisableStatusCodes: value }) + } + /> + {parsedAutoDisableStatusCodes.ok && + parsedAutoDisableStatusCodes.tokens.length > 0 && ( +
+ {parsedAutoDisableStatusCodes.tokens.map((token) => ( + + {token} + + ))} +
+ )} + {!parsedAutoDisableStatusCodes.ok && ( + + {t('自动禁用状态码格式不正确')} + {parsedAutoDisableStatusCodes.invalidTokens && + parsedAutoDisableStatusCodes.invalidTokens.length > 0 + ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join( + ', ', + )}` + : ''} + + )} Date: Mon, 12 Jan 2026 18:48:05 +0800 Subject: [PATCH 10/37] fix: chat2response setting ui (#2643) * fix: setting ui * fix: rm global.chat_completions_to_responses_policy * fix: rm global.chat_completions_to_responses_policy --- .../Setting/Model/SettingGlobalModel.jsx | 203 ++++++++++-------- 1 file changed, 114 insertions(+), 89 deletions(-) diff --git a/web/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/src/pages/Setting/Model/SettingGlobalModel.jsx index 197f297b..3d4cfd56 100644 --- a/web/src/pages/Setting/Model/SettingGlobalModel.jsx +++ b/web/src/pages/Setting/Model/SettingGlobalModel.jsx @@ -18,7 +18,16 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef } from 'react'; -import { Button, Col, Form, Row, Spin, Banner, Tag } from '@douyinfe/semi-ui'; +import { + Button, + Col, + Form, + Row, + Spin, + Banner, + Tag, + Divider, +} from '@douyinfe/semi-ui'; import { compareObjects, API, @@ -71,6 +80,18 @@ export default function SettingGlobalModel(props) { const [inputs, setInputs] = useState(defaultGlobalSettingInputs); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(defaultGlobalSettingInputs); + const chatCompletionsToResponsesPolicyKey = + 'global.chat_completions_to_responses_policy'; + + const setChatCompletionsToResponsesPolicyValue = (value) => { + setInputs((prev) => ({ + ...prev, + [chatCompletionsToResponsesPolicyKey]: value, + })); + if (refForm.current) { + refForm.current.setValue(chatCompletionsToResponsesPolicyKey, value); + } + }; const normalizeValueBeforeSave = (key, value) => { if (key === 'global.thinking_model_blacklist') { @@ -216,19 +237,29 @@ export default function SettingGlobalModel(props) {
- + + {t('ChatCompletions→Responses 兼容配置')} + + 测试版 + + + } + > - {t('ChatCompletions→Responses 兼容配置')}{' '} - - Alpha - - - } description={t( '提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。', )} @@ -238,77 +269,12 @@ export default function SettingGlobalModel(props) { -
- - - -
- -
- - - - - setInputs({ - ...inputs, - 'global.chat_completions_to_responses_policy': value, - }) + setInputs((prev) => ({ + ...prev, + [chatCompletionsToResponsesPolicyKey]: value, + })) } /> + + + +
+ + + +
+ +
- + + {t('连接保活设置')} + + } + > Date: Mon, 12 Jan 2026 18:49:05 +0800 Subject: [PATCH 11/37] Merge pull request #2627 from seefs001/feature/channel-test-param-override feat: channel testing supports parameter overriding --- controller/channel-test.go | 22 +++++++++++ relay/common/override.go | 22 +++++++---- relay/common/relay_info.go | 1 + .../channels/modals/EditChannelModal.jsx | 38 ++++++++++++++----- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index f9657edb..2ae8b0ef 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -193,6 +193,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } } + info.IsChannelTest = true info.InitChannelMeta(c) err = helper.ModelMappedHelper(c, info, request) @@ -309,6 +310,27 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed), } } + + //jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + //if err != nil { + // return testResult{ + // context: c, + // localErr: err, + // newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed), + // } + //} + + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info)) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid), + } + } + } + requestBody := bytes.NewBuffer(jsonData) c.Request.Body = io.NopCloser(requestBody) resp, err := adaptor.DoRequest(c, info, requestBody) diff --git a/relay/common/override.go b/relay/common/override.go index 872c960f..1a0c2478 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -570,18 +570,19 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str // BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。 // 目前内置以下字段: -// - model:优先使用上游模型名(UpstreamModelName),若不存在则回落到原始模型名(OriginModelName)。 -// - upstream_model:始终为通道映射后的上游模型名。 +// - upstream_model/model:始终为通道映射后的上游模型名。 // - original_model:请求最初指定的模型名。 +// - request_path:请求路径 +// - is_channel_test:是否为渠道测试请求(同 is_test)。 func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { - if info == nil || info.ChannelMeta == nil { + if info == nil { return nil } ctx := make(map[string]interface{}) - if info.UpstreamModelName != "" { - ctx["model"] = info.UpstreamModelName - ctx["upstream_model"] = info.UpstreamModelName + if info.ChannelMeta != nil && info.ChannelMeta.UpstreamModelName != "" { + ctx["model"] = info.ChannelMeta.UpstreamModelName + ctx["upstream_model"] = info.ChannelMeta.UpstreamModelName } if info.OriginModelName != "" { ctx["original_model"] = info.OriginModelName @@ -590,8 +591,13 @@ func BuildParamOverrideContext(info *RelayInfo) map[string]interface{} { } } - if len(ctx) == 0 { - return nil + if info.RequestURLPath != "" { + requestPath := info.RequestURLPath + if requestPath != "" { + ctx["request_path"] = requestPath + } } + + ctx["is_channel_test"] = info.IsChannelTest return ctx } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 1b9762fe..56f16fbf 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -115,6 +115,7 @@ type RelayInfo struct { SendResponseCount int FinalPreConsumedQuota int // 最终预消耗的配额 IsClaudeBetaQuery bool // /v1/messages?beta=true + IsChannelTest bool // channel test request PriceData types.PriceData diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 0abd3fdf..722c1e8a 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -199,17 +199,11 @@ const EditChannelModal = (props) => { if (!trimmed) return []; try { const parsed = JSON.parse(trimmed); - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) - ) { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return []; } const values = Object.values(parsed) - .map((value) => - typeof value === 'string' ? value.trim() : undefined, - ) + .map((value) => (typeof value === 'string' ? value.trim() : undefined)) .filter((value) => value); return Array.from(new Set(values)); } catch (error) { @@ -509,6 +503,18 @@ const EditChannelModal = (props) => { //setAutoBan }; + const formatJsonField = (fieldName) => { + const rawValue = (inputs?.[fieldName] ?? '').trim(); + if (!rawValue) return; + + try { + const parsed = JSON.parse(rawValue); + handleInputChange(fieldName, JSON.stringify(parsed, null, 2)); + } catch (error) { + showError(`${t('JSON格式错误')}: ${error.message}`); + } + }; + const loadChannel = async () => { setLoading(true); let res = await API.get(`/api/channel/${channelId}`); @@ -2812,6 +2818,12 @@ const EditChannelModal = (props) => { > {t('新格式模板')} + formatJsonField('param_override')} + > + {t('格式化')} + } showClear @@ -2852,6 +2864,12 @@ const EditChannelModal = (props) => { > {t('填入模板')} + formatJsonField('header_override')} + > + {t('格式化')} +
@@ -3181,7 +3199,9 @@ const EditChannelModal = (props) => { ? inputs.models.map(String) : []; const incoming = modelIds.map(String); - const nextModels = Array.from(new Set([...existingModels, ...incoming])); + const nextModels = Array.from( + new Set([...existingModels, ...incoming]), + ); handleInputChange('models', nextModels); if (formApiRef.current) { From 9a7fa9d139b1dc206c2f06b89aa95a12c35db596 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 14 Jan 2026 14:34:12 +0800 Subject: [PATCH 12/37] feat: customizable automatic retry status codes --- controller/option.go | 9 +++ controller/relay.go | 27 ++----- model/option.go | 3 + .../operation_setting/status_code_ranges.go | 63 ++++++++++++---- .../status_code_ranges_test.go | 27 +++++++ .../settings/HttpStatusCodeRulesInput.jsx | 71 +++++++++++++++++++ .../components/settings/OperationSetting.jsx | 1 + web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/zh.json | 2 + .../Setting/Operation/SettingsMonitoring.jsx | 70 +++++++++--------- 10 files changed, 204 insertions(+), 71 deletions(-) create mode 100644 web/src/components/settings/HttpStatusCodeRulesInput.jsx diff --git a/controller/option.go b/controller/option.go index a2db9532..959f2f9b 100644 --- a/controller/option.go +++ b/controller/option.go @@ -187,6 +187,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "AutomaticRetryStatusCodes": + _, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "console_setting.api_info": err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo") if err != nil { diff --git a/controller/relay.go b/controller/relay.go index 72ea3e24..4fba947f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -21,6 +21,7 @@ import ( "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -316,30 +317,14 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b if _, ok := c.Get("specific_channel_id"); ok { return false } - if openaiErr.StatusCode == http.StatusTooManyRequests { - return true - } - if openaiErr.StatusCode == 307 { - return true - } - if openaiErr.StatusCode/100 == 5 { - // 超时不重试 - if openaiErr.StatusCode == 504 || openaiErr.StatusCode == 524 { - return false - } - return true - } - if openaiErr.StatusCode == http.StatusBadRequest { + code := openaiErr.StatusCode + if code >= 200 && code < 300 { return false } - if openaiErr.StatusCode == 408 { - // azure处理超时不重试 - return false + if code < 100 || code > 599 { + return true } - if openaiErr.StatusCode/100 == 2 { - return false - } - return true + return operation_setting.ShouldRetryByStatusCode(code) } func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { diff --git a/model/option.go b/model/option.go index 24cf7862..e268cf57 100644 --- a/model/option.go +++ b/model/option.go @@ -144,6 +144,7 @@ func InitOptionMap() { common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() + common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) // 自动添加所有注册的模型配置 @@ -447,6 +448,8 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.AutomaticDisableKeywordsFromString(value) case "AutomaticDisableStatusCodes": err = operation_setting.AutomaticDisableStatusCodesFromString(value) + case "AutomaticRetryStatusCodes": + err = operation_setting.AutomaticRetryStatusCodesFromString(value) case "StreamCacheQueueLength": setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": diff --git a/setting/operation_setting/status_code_ranges.go b/setting/operation_setting/status_code_ranges.go index 7a763008..698c87c9 100644 --- a/setting/operation_setting/status_code_ranges.go +++ b/setting/operation_setting/status_code_ranges.go @@ -14,19 +14,20 @@ type StatusCodeRange struct { var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}} +// Default behavior matches legacy hardcoded retry rules in controller/relay.go shouldRetry: +// retry for 1xx, 3xx, 4xx(except 400/408), 5xx(except 504/524), and no retry for 2xx. +var AutomaticRetryStatusCodeRanges = []StatusCodeRange{ + {Start: 100, End: 199}, + {Start: 300, End: 399}, + {Start: 401, End: 407}, + {Start: 409, End: 499}, + {Start: 500, End: 503}, + {Start: 505, End: 523}, + {Start: 525, End: 599}, +} + func AutomaticDisableStatusCodesToString() string { - if len(AutomaticDisableStatusCodeRanges) == 0 { - return "" - } - parts := make([]string, 0, len(AutomaticDisableStatusCodeRanges)) - for _, r := range AutomaticDisableStatusCodeRanges { - if r.Start == r.End { - parts = append(parts, strconv.Itoa(r.Start)) - continue - } - parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End)) - } - return strings.Join(parts, ",") + return statusCodeRangesToString(AutomaticDisableStatusCodeRanges) } func AutomaticDisableStatusCodesFromString(s string) error { @@ -39,10 +40,46 @@ func AutomaticDisableStatusCodesFromString(s string) error { } func ShouldDisableByStatusCode(code int) bool { + return shouldMatchStatusCodeRanges(AutomaticDisableStatusCodeRanges, code) +} + +func AutomaticRetryStatusCodesToString() string { + return statusCodeRangesToString(AutomaticRetryStatusCodeRanges) +} + +func AutomaticRetryStatusCodesFromString(s string) error { + ranges, err := ParseHTTPStatusCodeRanges(s) + if err != nil { + return err + } + AutomaticRetryStatusCodeRanges = ranges + return nil +} + +func ShouldRetryByStatusCode(code int) bool { + return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code) +} + +func statusCodeRangesToString(ranges []StatusCodeRange) string { + if len(ranges) == 0 { + return "" + } + parts := make([]string, 0, len(ranges)) + for _, r := range ranges { + if r.Start == r.End { + parts = append(parts, strconv.Itoa(r.Start)) + continue + } + parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End)) + } + return strings.Join(parts, ",") +} + +func shouldMatchStatusCodeRanges(ranges []StatusCodeRange, code int) bool { if code < 100 || code > 599 { return false } - for _, r := range AutomaticDisableStatusCodeRanges { + for _, r := range ranges { if code < r.Start { return false } diff --git a/setting/operation_setting/status_code_ranges_test.go b/setting/operation_setting/status_code_ranges_test.go index 1712efd7..5801824a 100644 --- a/setting/operation_setting/status_code_ranges_test.go +++ b/setting/operation_setting/status_code_ranges_test.go @@ -50,3 +50,30 @@ func TestShouldDisableByStatusCode(t *testing.T) { require.True(t, ShouldDisableByStatusCode(500)) require.False(t, ShouldDisableByStatusCode(200)) } + +func TestShouldRetryByStatusCode(t *testing.T) { + orig := AutomaticRetryStatusCodeRanges + t.Cleanup(func() { AutomaticRetryStatusCodeRanges = orig }) + + AutomaticRetryStatusCodeRanges = []StatusCodeRange{ + {Start: 429, End: 429}, + {Start: 500, End: 599}, + } + + require.True(t, ShouldRetryByStatusCode(429)) + require.True(t, ShouldRetryByStatusCode(500)) + require.False(t, ShouldRetryByStatusCode(400)) + require.False(t, ShouldRetryByStatusCode(200)) +} + +func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) { + require.False(t, ShouldRetryByStatusCode(200)) + require.False(t, ShouldRetryByStatusCode(400)) + require.True(t, ShouldRetryByStatusCode(401)) + require.False(t, ShouldRetryByStatusCode(408)) + require.True(t, ShouldRetryByStatusCode(429)) + require.True(t, ShouldRetryByStatusCode(500)) + require.False(t, ShouldRetryByStatusCode(504)) + require.False(t, ShouldRetryByStatusCode(524)) + require.True(t, ShouldRetryByStatusCode(599)) +} diff --git a/web/src/components/settings/HttpStatusCodeRulesInput.jsx b/web/src/components/settings/HttpStatusCodeRulesInput.jsx new file mode 100644 index 00000000..361bc19e --- /dev/null +++ b/web/src/components/settings/HttpStatusCodeRulesInput.jsx @@ -0,0 +1,71 @@ +/* +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 from 'react'; +import { Form, Tag, Typography } from '@douyinfe/semi-ui'; + +export default function HttpStatusCodeRulesInput(props) { + const { Text } = Typography; + const { + label, + field, + placeholder, + extraText, + onChange, + parsed, + invalidText, + } = props; + + return ( + <> + + {parsed?.ok && parsed.tokens?.length > 0 && ( +
+ {parsed.tokens.map((token) => ( + + {token} + + ))} +
+ )} + {!parsed?.ok && ( + + {invalidText} + {parsed?.invalidTokens && parsed.invalidTokens.length > 0 + ? `: ${parsed.invalidTokens.join(', ')}` + : ''} + + )} + + ); +} + diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index 4a77bcf1..9ee5fd00 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -71,6 +71,7 @@ const OperationSetting = () => { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', + AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */, 'checkin_setting.enabled': false, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6d55544..88619a7a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1925,6 +1925,8 @@ "自动禁用关键词": "Automatic disable keywords", "自动禁用状态码": "Auto-disable status codes", "自动禁用状态码格式不正确": "Invalid auto-disable status code format", + "自动重试状态码": "Auto-retry status codes", + "自动重试状态码格式不正确": "Invalid auto-retry status code format", "支持填写单个状态码或范围(含首尾),使用逗号分隔": "Supports single status codes or inclusive ranges; separate with commas", "例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599", "自动选择": "Auto Select", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e91f50a4..1b6bea41 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1911,6 +1911,8 @@ "自动禁用关键词": "自动禁用关键词", "自动禁用状态码": "自动禁用状态码", "自动禁用状态码格式不正确": "自动禁用状态码格式不正确", + "自动重试状态码": "自动重试状态码", + "自动重试状态码格式不正确": "自动重试状态码格式不正确", "支持填写单个状态码或范围(含首尾),使用逗号分隔": "支持填写单个状态码或范围(含首尾),使用逗号分隔", "例如:401, 403, 429, 500-599": "例如:401,403,429,500-599", "自动选择": "自动选择", diff --git a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx index 9715ef3c..6e174347 100644 --- a/web/src/pages/Setting/Operation/SettingsMonitoring.jsx +++ b/web/src/pages/Setting/Operation/SettingsMonitoring.jsx @@ -24,8 +24,6 @@ import { Form, Row, Spin, - Tag, - Typography, } from '@douyinfe/semi-ui'; import { compareObjects, @@ -34,13 +32,12 @@ import { showSuccess, showWarning, parseHttpStatusCodeRules, - verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; +import HttpStatusCodeRulesInput from '../../../components/settings/HttpStatusCodeRulesInput'; export default function SettingsMonitoring(props) { const { t } = useTranslation(); - const { Text } = Typography; const [loading, setLoading] = useState(false); const [inputs, setInputs] = useState({ ChannelDisableThreshold: '', @@ -49,6 +46,7 @@ export default function SettingsMonitoring(props) { AutomaticEnableChannelEnabled: false, AutomaticDisableKeywords: '', AutomaticDisableStatusCodes: '401', + AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599', 'monitor_setting.auto_test_channel_enabled': false, 'monitor_setting.auto_test_channel_minutes': 10, }); @@ -57,6 +55,9 @@ export default function SettingsMonitoring(props) { const parsedAutoDisableStatusCodes = parseHttpStatusCodeRules( inputs.AutomaticDisableStatusCodes || '', ); + const parsedAutoRetryStatusCodes = parseHttpStatusCodeRules( + inputs.AutomaticRetryStatusCodes || '', + ); function onSubmit() { const updateArray = compareObjects(inputs, inputsRow); @@ -69,16 +70,24 @@ export default function SettingsMonitoring(props) { : ''; return showError(`${t('自动禁用状态码格式不正确')}${details}`); } + if (!parsedAutoRetryStatusCodes.ok) { + const details = + parsedAutoRetryStatusCodes.invalidTokens && + parsedAutoRetryStatusCodes.invalidTokens.length > 0 + ? `: ${parsedAutoRetryStatusCodes.invalidTokens.join(', ')}` + : ''; + return showError(`${t('自动重试状态码格式不正确')}${details}`); + } const requestQueue = updateArray.map((item) => { let value = ''; if (typeof inputs[item.key] === 'boolean') { value = String(inputs[item.key]); } else { - if (item.key === 'AutomaticDisableStatusCodes') { - value = parsedAutoDisableStatusCodes.normalized; - } else { - value = inputs[item.key]; - } + const normalizedMap = { + AutomaticDisableStatusCodes: parsedAutoDisableStatusCodes.normalized, + AutomaticRetryStatusCodes: parsedAutoRetryStatusCodes.normalized, + }; + value = normalizedMap[item.key] ?? inputs[item.key]; } return API.put('/api/option/', { key: item.key, @@ -233,7 +242,7 @@ export default function SettingsMonitoring(props) { - setInputs({ ...inputs, AutomaticDisableStatusCodes: value }) } + parsed={parsedAutoDisableStatusCodes} + invalidText={t('自动禁用状态码格式不正确')} /> - {parsedAutoDisableStatusCodes.ok && - parsedAutoDisableStatusCodes.tokens.length > 0 && ( -
- {parsedAutoDisableStatusCodes.tokens.map((token) => ( - - {token} - - ))} -
+ - {t('自动禁用状态码格式不正确')} - {parsedAutoDisableStatusCodes.invalidTokens && - parsedAutoDisableStatusCodes.invalidTokens.length > 0 - ? `: ${parsedAutoDisableStatusCodes.invalidTokens.join( - ', ', - )}` - : ''} -
- )} + field={'AutomaticRetryStatusCodes'} + onChange={(value) => + setInputs({ ...inputs, AutomaticRetryStatusCodes: value }) + } + parsed={parsedAutoRetryStatusCodes} + invalidText={t('自动重试状态码格式不正确')} + /> Date: Wed, 14 Jan 2026 22:29:43 +0800 Subject: [PATCH 13/37] feat: codex channel (#2652) * feat: codex channel * feat: codex channel * feat: codex oauth flow * feat: codex refresh cred * feat: codex usage * fix: codex err message detail * fix: codex setting ui * feat: codex refresh cred task * fix: import err * fix: codex store must be false * fix: chat -> responses tool call * fix: chat -> responses tool call --- common/api_type.go | 2 + constant/api_type.go | 1 + constant/channel.go | 3 + controller/channel.go | 53 ++++ controller/codex_oauth.go | 243 +++++++++++++++ controller/codex_usage.go | 124 ++++++++ dto/error.go | 6 +- dto/openai_response.go | 4 + main.go | 3 + relay/channel/codex/adaptor.go | 161 ++++++++++ relay/channel/codex/constants.go | 9 + relay/channel/codex/oauth_key.go | 30 ++ relay/channel/openai/chat_via_responses.go | 183 +++++++++-- relay/common/relay_info.go | 1 + relay/compatible_handler.go | 17 +- relay/relay_adaptor.go | 3 + router/api-router.go | 6 + service/codex_credential_refresh.go | 104 +++++++ service/codex_credential_refresh_task.go | 140 +++++++++ service/codex_oauth.go | 288 ++++++++++++++++++ service/codex_wham_usage.go | 56 ++++ service/openaicompat/chat_to_responses.go | 96 +++++- .../table/channels/modals/CodexOAuthModal.jsx | 151 +++++++++ .../table/channels/modals/CodexUsageModal.jsx | 190 ++++++++++++ .../channels/modals/EditChannelModal.jsx | 177 ++++++++++- web/src/constants/channel.constants.js | 5 + web/src/helpers/render.jsx | 1 + web/src/hooks/channels/useChannelsData.jsx | 27 ++ 28 files changed, 2052 insertions(+), 32 deletions(-) create mode 100644 controller/codex_oauth.go create mode 100644 controller/codex_usage.go create mode 100644 relay/channel/codex/adaptor.go create mode 100644 relay/channel/codex/constants.go create mode 100644 relay/channel/codex/oauth_key.go create mode 100644 service/codex_credential_refresh.go create mode 100644 service/codex_credential_refresh_task.go create mode 100644 service/codex_oauth.go create mode 100644 service/codex_wham_usage.go create mode 100644 web/src/components/table/channels/modals/CodexOAuthModal.jsx create mode 100644 web/src/components/table/channels/modals/CodexUsageModal.jsx diff --git a/common/api_type.go b/common/api_type.go index 4f5c1826..39c1fe9a 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -73,6 +73,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeMiniMax case constant.ChannelTypeReplicate: apiType = constant.APITypeReplicate + case constant.ChannelTypeCodex: + apiType = constant.APITypeCodex } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/api_type.go b/constant/api_type.go index 32b48bcd..536ebd2c 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -35,5 +35,6 @@ const ( APITypeSubmodel APITypeMiniMax APITypeReplicate + APITypeCodex APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 6d3a5d92..48502bed 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -54,6 +54,7 @@ const ( ChannelTypeDoubaoVideo = 54 ChannelTypeSora = 55 ChannelTypeReplicate = 56 + ChannelTypeCodex = 57 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -116,6 +117,7 @@ var ChannelBaseURLs = []string{ "https://ark.cn-beijing.volces.com", //54 "https://api.openai.com", //55 "https://api.replicate.com", //56 + "https://chatgpt.com", //57 } var ChannelTypeNames = map[int]string{ @@ -172,6 +174,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeDoubaoVideo: "DoubaoVideo", ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", + ChannelTypeCodex: "Codex", } func GetChannelTypeName(channelType int) string { diff --git a/controller/channel.go b/controller/channel.go index cb97aa8c..3ac29d7c 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1,11 +1,13 @@ package controller import ( + "context" "encoding/json" "fmt" "net/http" "strconv" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -604,9 +606,60 @@ func validateChannel(channel *model.Channel, isAdd bool) error { } } + // Codex OAuth key validation (optional, only when JSON object is provided) + if channel.Type == constant.ChannelTypeCodex { + trimmedKey := strings.TrimSpace(channel.Key) + if isAdd || trimmedKey != "" { + if !strings.HasPrefix(trimmedKey, "{") { + return fmt.Errorf("Codex key must be a valid JSON object") + } + var keyMap map[string]any + if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil { + return fmt.Errorf("Codex key must be a valid JSON object") + } + if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { + return fmt.Errorf("Codex key JSON must include access_token") + } + if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { + return fmt.Errorf("Codex key JSON must include account_id") + } + } + } + return nil } +func RefreshCodexChannelCredential(c *gin.Context) { + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "refreshed", + "data": gin.H{ + "expires_at": oauthKey.Expired, + "last_refresh": oauthKey.LastRefresh, + "account_id": oauthKey.AccountID, + "email": oauthKey.Email, + "channel_id": ch.Id, + "channel_type": ch.Type, + "channel_name": ch.Name, + }, + }) +} + type AddChannelRequest struct { Mode string `json:"mode"` MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` diff --git a/controller/codex_oauth.go b/controller/codex_oauth.go new file mode 100644 index 00000000..3c881ebb --- /dev/null +++ b/controller/codex_oauth.go @@ -0,0 +1,243 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/codex" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type codexOAuthCompleteRequest struct { + Input string `json:"input"` +} + +func codexOAuthSessionKey(channelID int, field string) string { + return fmt.Sprintf("codex_oauth_%s_%d", field, channelID) +} + +func parseCodexAuthorizationInput(input string) (code string, state string, err error) { + v := strings.TrimSpace(input) + if v == "" { + return "", "", errors.New("empty input") + } + if strings.Contains(v, "#") { + parts := strings.SplitN(v, "#", 2) + code = strings.TrimSpace(parts[0]) + state = strings.TrimSpace(parts[1]) + return code, state, nil + } + if strings.Contains(v, "code=") { + u, parseErr := url.Parse(v) + if parseErr == nil { + q := u.Query() + code = strings.TrimSpace(q.Get("code")) + state = strings.TrimSpace(q.Get("state")) + return code, state, nil + } + q, parseErr := url.ParseQuery(v) + if parseErr == nil { + code = strings.TrimSpace(q.Get("code")) + state = strings.TrimSpace(q.Get("state")) + return code, state, nil + } + } + + code = v + return code, "", nil +} + +func StartCodexOAuth(c *gin.Context) { + startCodexOAuthWithChannelID(c, 0) +} + +func StartCodexOAuthForChannel(c *gin.Context) { + channelID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) + return + } + startCodexOAuthWithChannelID(c, channelID) +} + +func startCodexOAuthWithChannelID(c *gin.Context, channelID int) { + if channelID > 0 { + ch, err := model.GetChannelById(channelID, false) + if err != nil { + common.ApiError(c, err) + return + } + if ch == nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"}) + return + } + if ch.Type != constant.ChannelTypeCodex { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"}) + return + } + } + + flow, err := service.CreateCodexOAuthAuthorizationFlow() + if err != nil { + common.ApiError(c, err) + return + } + + session := sessions.Default(c) + session.Set(codexOAuthSessionKey(channelID, "state"), flow.State) + session.Set(codexOAuthSessionKey(channelID, "verifier"), flow.Verifier) + session.Set(codexOAuthSessionKey(channelID, "created_at"), time.Now().Unix()) + _ = session.Save() + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "authorize_url": flow.AuthorizeURL, + }, + }) +} + +func CompleteCodexOAuth(c *gin.Context) { + completeCodexOAuthWithChannelID(c, 0) +} + +func CompleteCodexOAuthForChannel(c *gin.Context) { + channelID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) + return + } + completeCodexOAuthWithChannelID(c, channelID) +} + +func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) { + req := codexOAuthCompleteRequest{} + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + code, state, err := parseCodexAuthorizationInput(req.Input) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + if strings.TrimSpace(code) == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "missing authorization code"}) + return + } + if strings.TrimSpace(state) == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "missing state in input"}) + return + } + + if channelID > 0 { + ch, err := model.GetChannelById(channelID, false) + if err != nil { + common.ApiError(c, err) + return + } + if ch == nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"}) + return + } + if ch.Type != constant.ChannelTypeCodex { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"}) + return + } + } + + session := sessions.Default(c) + expectedState, _ := session.Get(codexOAuthSessionKey(channelID, "state")).(string) + verifier, _ := session.Get(codexOAuthSessionKey(channelID, "verifier")).(string) + if strings.TrimSpace(expectedState) == "" || strings.TrimSpace(verifier) == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "oauth flow not started or session expired"}) + return + } + if state != expectedState { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "state mismatch"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + tokenRes, err := service.ExchangeCodexAuthorizationCode(ctx, code, verifier) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + accountID, ok := service.ExtractCodexAccountIDFromJWT(tokenRes.AccessToken) + if !ok { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "failed to extract account_id from access_token"}) + return + } + email, _ := service.ExtractEmailFromJWT(tokenRes.AccessToken) + + key := codex.OAuthKey{ + AccessToken: tokenRes.AccessToken, + RefreshToken: tokenRes.RefreshToken, + AccountID: accountID, + LastRefresh: time.Now().Format(time.RFC3339), + Expired: tokenRes.ExpiresAt.Format(time.RFC3339), + Email: email, + Type: "codex", + } + encoded, err := common.Marshal(key) + if err != nil { + common.ApiError(c, err) + return + } + + session.Delete(codexOAuthSessionKey(channelID, "state")) + session.Delete(codexOAuthSessionKey(channelID, "verifier")) + session.Delete(codexOAuthSessionKey(channelID, "created_at")) + _ = session.Save() + + if channelID > 0 { + if err := model.DB.Model(&model.Channel{}).Where("id = ?", channelID).Update("key", string(encoded)).Error; err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + service.ResetProxyClientCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "saved", + "data": gin.H{ + "channel_id": channelID, + "account_id": accountID, + "email": email, + "expires_at": key.Expired, + "last_refresh": key.LastRefresh, + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "generated", + "data": gin.H{ + "key": string(encoded), + "account_id": accountID, + "email": email, + "expires_at": key.Expired, + "last_refresh": key.LastRefresh, + }, + }) +} diff --git a/controller/codex_usage.go b/controller/codex_usage.go new file mode 100644 index 00000000..61614b46 --- /dev/null +++ b/controller/codex_usage.go @@ -0,0 +1,124 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel/codex" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +func GetCodexChannelUsage(c *gin.Context) { + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("invalid channel id: %w", err)) + return + } + + ch, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, err) + return + } + if ch == nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"}) + return + } + if ch.Type != constant.ChannelTypeCodex { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"}) + return + } + if ch.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "multi-key channel is not supported"}) + return + } + + oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + accessToken := strings.TrimSpace(oauthKey.AccessToken) + accountID := strings.TrimSpace(oauthKey.AccountID) + if accessToken == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"}) + return + } + if accountID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"}) + return + } + + client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) + if err != nil { + common.ApiError(c, err) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + statusCode, body, err := service.FetchCodexWhamUsage(ctx, client, ch.GetBaseURL(), accessToken, accountID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && strings.TrimSpace(oauthKey.RefreshToken) != "" { + refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer refreshCancel() + + res, refreshErr := service.RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken) + if refreshErr == nil { + oauthKey.AccessToken = res.AccessToken + oauthKey.RefreshToken = res.RefreshToken + oauthKey.LastRefresh = time.Now().Format(time.RFC3339) + oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339) + if strings.TrimSpace(oauthKey.Type) == "" { + oauthKey.Type = "codex" + } + + encoded, encErr := common.Marshal(oauthKey) + if encErr == nil { + _ = model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error + model.InitChannelCache() + service.ResetProxyClientCache() + } + + ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel2() + statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + } + } + + var payload any + if json.Unmarshal(body, &payload) != nil { + payload = string(body) + } + + ok := statusCode >= 200 && statusCode < 300 + resp := gin.H{ + "success": ok, + "message": "", + "upstream_status": statusCode, + "data": payload, + } + if !ok { + resp["message"] = fmt.Sprintf("upstream status: %d", statusCode) + } + c.JSON(http.StatusOK, resp) +} diff --git a/dto/error.go b/dto/error.go index 78197765..be57407f 100644 --- a/dto/error.go +++ b/dto/error.go @@ -26,7 +26,8 @@ type GeneralErrorResponse struct { Msg string `json:"msg"` Err string `json:"err"` ErrorMsg string `json:"error_msg"` - Metadata json.RawMessage `json:"metadata,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Detail string `json:"detail,omitempty"` Header struct { Message string `json:"message"` } `json:"header"` @@ -79,6 +80,9 @@ func (e GeneralErrorResponse) ToMessage() string { if e.ErrorMsg != "" { return e.ErrorMsg } + if e.Detail != "" { + return e.Detail + } if e.Header.Message != "" { return e.Header.Message } diff --git a/dto/openai_response.go b/dto/openai_response.go index 16531e20..19ca9290 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -372,6 +372,10 @@ type ResponsesStreamResponse struct { Response *OpenAIResponsesResponse `json:"response,omitempty"` Delta string `json:"delta,omitempty"` Item *ResponsesOutput `json:"item,omitempty"` + // - response.function_call_arguments.delta + // - response.function_call_arguments.done + OutputIndex *int `json:"output_index,omitempty"` + ItemID string `json:"item_id,omitempty"` } // GetOpenAIError 从动态错误类型中提取OpenAIError结构 diff --git a/main.go b/main.go index 4c0fc8c6..1326b122 100644 --- a/main.go +++ b/main.go @@ -102,6 +102,9 @@ func main() { go controller.AutomaticallyTestChannels() + // Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day + service.StartCodexCredentialAutoRefreshTask() + if common.IsMasterNode && constant.UpdateTask { gopool.Go(func() { controller.UpdateMidjourneyTaskBulk() diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go new file mode 100644 index 00000000..92d855a5 --- /dev/null +++ b/relay/channel/codex/adaptor.go @@ -0,0 +1,161 @@ +package codex + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("codex channel: endpoint not supported") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + if info != nil && info.ChannelSetting.SystemPrompt != "" { + systemPrompt := info.ChannelSetting.SystemPrompt + + if len(request.Instructions) == 0 { + if b, err := common.Marshal(systemPrompt); err == nil { + request.Instructions = b + } else { + return nil, err + } + } else if info.ChannelSetting.SystemPromptOverride { + var existing string + if err := common.Unmarshal(request.Instructions, &existing); err == nil { + existing = strings.TrimSpace(existing) + if existing == "" { + if b, err := common.Marshal(systemPrompt); err == nil { + request.Instructions = b + } else { + return nil, err + } + } else { + if b, err := common.Marshal(systemPrompt + "\n" + existing); err == nil { + request.Instructions = b + } else { + return nil, err + } + } + } else { + if b, err := common.Marshal(systemPrompt); err == nil { + request.Instructions = b + } else { + return nil, err + } + } + } + } + + // codex: store must be false + request.Store = json.RawMessage("false") + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode != relayconstant.RelayModeResponses { + return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest) + } + + if info.IsStream { + return openai.OaiResponsesStreamHandler(c, info, resp) + } + return openai.OaiResponsesHandler(c, info, resp) +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode != relayconstant.RelayModeResponses { + return "", errors.New("codex channel: only /v1/responses is supported") + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, "/backend-api/codex/responses", info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + + key := strings.TrimSpace(info.ApiKey) + if !strings.HasPrefix(key, "{") { + return errors.New("codex channel: key must be a JSON object") + } + + oauthKey, err := ParseOAuthKey(key) + if err != nil { + return err + } + + accessToken := strings.TrimSpace(oauthKey.AccessToken) + accountID := strings.TrimSpace(oauthKey.AccountID) + + if accessToken == "" { + return errors.New("codex channel: access_token is required") + } + if accountID == "" { + return errors.New("codex channel: account_id is required") + } + + req.Set("Authorization", "Bearer "+accessToken) + req.Set("chatgpt-account-id", accountID) + + if req.Get("OpenAI-Beta") == "" { + req.Set("OpenAI-Beta", "responses=experimental") + } + if req.Get("originator") == "" { + req.Set("originator", "codex_cli_rs") + } + + return nil +} diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go new file mode 100644 index 00000000..461e033a --- /dev/null +++ b/relay/channel/codex/constants.go @@ -0,0 +1,9 @@ +package codex + +var ModelList = []string{ + "gpt-5", "gpt-5-codex", "gpt-5-codex-mini", + "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", + "gpt-5.2", "gpt-5.2-codex", +} + +const ChannelName = "codex" diff --git a/relay/channel/codex/oauth_key.go b/relay/channel/codex/oauth_key.go new file mode 100644 index 00000000..bf143f81 --- /dev/null +++ b/relay/channel/codex/oauth_key.go @@ -0,0 +1,30 @@ +package codex + +import ( + "errors" + + "github.com/QuantumNous/new-api/common" +) + +type OAuthKey struct { + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + + AccountID string `json:"account_id,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` + Email string `json:"email,omitempty"` + Type string `json:"type,omitempty"` + Expired string `json:"expired,omitempty"` +} + +func ParseOAuthKey(raw string) (*OAuthKey, error) { + if raw == "" { + return nil, errors.New("codex channel: empty oauth key") + } + var key OAuthKey + if err := common.Unmarshal([]byte(raw), &key); err != nil { + return nil, errors.New("codex channel: invalid oauth key json") + } + return &key, nil +} diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 5965613c..83f9734c 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -26,14 +26,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp defer service.CloseResponseBodyGracefully(resp) var responsesResp dto.OpenAIResponsesResponse - const maxResponseBodyBytes = 10 << 20 // 10MB - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBodyBytes+1)) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } - if int64(len(body)) > maxResponseBodyBytes { - return nil, types.NewOpenAIError(fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) - } if err := common.Unmarshal(body, &responsesResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) @@ -77,12 +73,99 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo var ( usage = &dto.Usage{} - textBuilder strings.Builder + outputText strings.Builder + usageText strings.Builder sentStart bool sentStop bool + sawToolCall bool streamErr *types.NewAPIError ) + toolCallIndexByID := make(map[string]int) + toolCallNameByID := make(map[string]string) + toolCallArgsByID := make(map[string]string) + toolCallNameSent := make(map[string]bool) + toolCallCanonicalIDByItemID := make(map[string]string) + + sendStartIfNeeded := func() bool { + if sentStart { + return true + } + if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + sentStart = true + return true + } + + sendToolCallDelta := func(callID string, name string, argsDelta string) bool { + if callID == "" { + return true + } + if outputText.Len() > 0 { + // Prefer streaming assistant text over tool calls to match non-stream behavior. + return true + } + if !sendStartIfNeeded() { + return false + } + + idx, ok := toolCallIndexByID[callID] + if !ok { + idx = len(toolCallIndexByID) + toolCallIndexByID[callID] = idx + } + if name != "" { + toolCallNameByID[callID] = name + } + if toolCallNameByID[callID] != "" { + name = toolCallNameByID[callID] + } + + tool := dto.ToolCallResponse{ + ID: callID, + Type: "function", + Function: dto.FunctionResponse{ + Arguments: argsDelta, + }, + } + tool.SetIndex(idx) + if name != "" && !toolCallNameSent[callID] { + tool.Function.Name = name + toolCallNameSent[callID] = true + } + + chunk := &dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + ToolCalls: []dto.ToolCallResponse{tool}, + }, + }, + }, + } + if err := helper.ObjectData(c, chunk); err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + sawToolCall = true + + // Include tool call data in the local builder for fallback token estimation. + if tool.Function.Name != "" { + usageText.WriteString(tool.Function.Name) + } + if argsDelta != "" { + usageText.WriteString(argsDelta) + } + return true + } + helper.StreamScannerHandler(c, resp, info, func(data string) bool { if streamErr != nil { return false @@ -106,16 +189,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } case "response.output_text.delta": - if !sentStart { - if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { - streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) - return false - } - sentStart = true + if !sendStartIfNeeded() { + return false } if streamResp.Delta != "" { - textBuilder.WriteString(streamResp.Delta) + outputText.WriteString(streamResp.Delta) + usageText.WriteString(streamResp.Delta) delta := streamResp.Delta chunk := &dto.ChatCompletionsStreamResponse{ Id: responseId, @@ -137,6 +217,59 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } } + case "response.output_item.added", "response.output_item.done": + if streamResp.Item == nil { + break + } + if streamResp.Item.Type != "function_call" { + break + } + + itemID := strings.TrimSpace(streamResp.Item.ID) + callID := strings.TrimSpace(streamResp.Item.CallId) + if callID == "" { + callID = itemID + } + if itemID != "" && callID != "" { + toolCallCanonicalIDByItemID[itemID] = callID + } + name := strings.TrimSpace(streamResp.Item.Name) + if name != "" { + toolCallNameByID[callID] = name + } + + newArgs := streamResp.Item.Arguments + prevArgs := toolCallArgsByID[callID] + argsDelta := "" + if newArgs != "" { + if strings.HasPrefix(newArgs, prevArgs) { + argsDelta = newArgs[len(prevArgs):] + } else { + argsDelta = newArgs + } + toolCallArgsByID[callID] = newArgs + } + + if !sendToolCallDelta(callID, name, argsDelta) { + return false + } + + case "response.function_call_arguments.delta": + itemID := strings.TrimSpace(streamResp.ItemID) + callID := toolCallCanonicalIDByItemID[itemID] + if callID == "" { + callID = itemID + } + if callID == "" { + break + } + toolCallArgsByID[callID] += streamResp.Delta + if !sendToolCallDelta(callID, "", streamResp.Delta) { + return false + } + + case "response.function_call_arguments.done": + case "response.completed": if streamResp.Response != nil { if streamResp.Response.Model != "" { @@ -170,15 +303,15 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } } - if !sentStart { - if err := helper.ObjectData(c, helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)); err != nil { - streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) - return false - } - sentStart = true + if !sendStartIfNeeded() { + return false } if !sentStop { - stop := helper.GenerateStopResponse(responseId, createAt, model, "stop") + finishReason := "stop" + if sawToolCall && outputText.Len() == 0 { + finishReason = "tool_calls" + } + stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason) if err := helper.ObjectData(c, stop); err != nil { streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) return false @@ -196,8 +329,6 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError) return false - case "response.output_item.added", "response.output_item.done": - default: } @@ -209,7 +340,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } if usage.TotalTokens == 0 { - usage = service.ResponseText2Usage(c, textBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) } if !sentStart { @@ -218,7 +349,11 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } } if !sentStop { - stop := helper.GenerateStopResponse(responseId, createAt, model, "stop") + finishReason := "stop" + if sawToolCall && outputText.Len() == 0 { + finishReason = "tool_calls" + } + stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason) if err := helper.ObjectData(c, stop); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 56f16fbf..4665573d 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -274,6 +274,7 @@ var streamSupportedChannels = map[int]bool{ constant.ChannelTypeZhipu_v4: true, constant.ChannelTypeAli: true, constant.ChannelTypeSubmodel: true, + constant.ChannelTypeCodex: true, } func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo { diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index d20dc93a..6c36f83d 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -75,10 +75,11 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } adaptor.Init(info) + passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled if info.RelayMode == relayconstant.RelayModeChatCompletions && - !model_setting.GetGlobalSettings().PassThroughRequestEnabled && + !passThroughGlobal && !info.ChannelSetting.PassThroughBodyEnabled && - service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) { + shouldChatCompletionsViaResponses(info) { applySystemPromptIfNeeded(c, info, request) usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) if newApiErr != nil { @@ -98,7 +99,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types var requestBody io.Reader - if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + if passThroughGlobal || info.ChannelSetting.PassThroughBodyEnabled { body, err := common.GetRequestBody(c) if err != nil { return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) @@ -216,6 +217,16 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return nil } +func shouldChatCompletionsViaResponses(info *relaycommon.RelayInfo) bool { + if info == nil { + return false + } + if info.RelayMode != relayconstant.RelayModeChatCompletions { + return false + } + return service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName) +} + func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) { if usage == nil { usage = &dto.Usage{ diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 9afa3b0c..3139c9a2 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/baidu_v2" "github.com/QuantumNous/new-api/relay/channel/claude" "github.com/QuantumNous/new-api/relay/channel/cloudflare" + "github.com/QuantumNous/new-api/relay/channel/codex" "github.com/QuantumNous/new-api/relay/channel/cohere" "github.com/QuantumNous/new-api/relay/channel/coze" "github.com/QuantumNous/new-api/relay/channel/deepseek" @@ -117,6 +118,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &minimax.Adaptor{} case constant.APITypeReplicate: return &replicate.Adaptor{} + case constant.APITypeCodex: + return &codex.Adaptor{} } return nil } diff --git a/router/api-router.go b/router/api-router.go index 9b2bd061..f3ae4d97 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -156,6 +156,12 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) + channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) + channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) + channelRoute.POST("/:id/codex/oauth/complete", controller.CompleteCodexOAuthForChannel) + channelRoute.POST("/:id/codex/refresh", controller.RefreshCodexChannelCredential) + channelRoute.GET("/:id/codex/usage", controller.GetCodexChannelUsage) channelRoute.POST("/ollama/pull", controller.OllamaPullModel) channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream) channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel) diff --git a/service/codex_credential_refresh.go b/service/codex_credential_refresh.go new file mode 100644 index 00000000..0290fe51 --- /dev/null +++ b/service/codex_credential_refresh.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" +) + +type CodexCredentialRefreshOptions struct { + ResetCaches bool +} + +type CodexOAuthKey struct { + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + + AccountID string `json:"account_id,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` + Email string `json:"email,omitempty"` + Type string `json:"type,omitempty"` + Expired string `json:"expired,omitempty"` +} + +func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) { + if strings.TrimSpace(raw) == "" { + return nil, errors.New("codex channel: empty oauth key") + } + var key CodexOAuthKey + if err := common.Unmarshal([]byte(raw), &key); err != nil { + return nil, errors.New("codex channel: invalid oauth key json") + } + return &key, nil +} + +func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) { + ch, err := model.GetChannelById(channelID, true) + if err != nil { + return nil, nil, err + } + if ch == nil { + return nil, nil, fmt.Errorf("channel not found") + } + if ch.Type != constant.ChannelTypeCodex { + return nil, nil, fmt.Errorf("channel type is not Codex") + } + + oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key)) + if err != nil { + return nil, nil, err + } + if strings.TrimSpace(oauthKey.RefreshToken) == "" { + return nil, nil, fmt.Errorf("codex channel: refresh_token is required to refresh credential") + } + + refreshCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + res, err := RefreshCodexOAuthToken(refreshCtx, oauthKey.RefreshToken) + if err != nil { + return nil, nil, err + } + + oauthKey.AccessToken = res.AccessToken + oauthKey.RefreshToken = res.RefreshToken + oauthKey.LastRefresh = time.Now().Format(time.RFC3339) + oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339) + if strings.TrimSpace(oauthKey.Type) == "" { + oauthKey.Type = "codex" + } + + if strings.TrimSpace(oauthKey.AccountID) == "" { + if accountID, ok := ExtractCodexAccountIDFromJWT(oauthKey.AccessToken); ok { + oauthKey.AccountID = accountID + } + } + if strings.TrimSpace(oauthKey.Email) == "" { + if email, ok := ExtractEmailFromJWT(oauthKey.AccessToken); ok { + oauthKey.Email = email + } + } + + encoded, err := common.Marshal(oauthKey) + if err != nil { + return nil, nil, err + } + + if err := model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error; err != nil { + return nil, nil, err + } + + if opts.ResetCaches { + model.InitChannelCache() + ResetProxyClientCache() + } + + return oauthKey, ch, nil +} diff --git a/service/codex_credential_refresh_task.go b/service/codex_credential_refresh_task.go new file mode 100644 index 00000000..627ab929 --- /dev/null +++ b/service/codex_credential_refresh_task.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + + "github.com/bytedance/gopkg/util/gopool" +) + +const ( + codexCredentialRefreshTickInterval = 10 * time.Minute + codexCredentialRefreshThreshold = 24 * time.Hour + codexCredentialRefreshBatchSize = 200 + codexCredentialRefreshTimeout = 15 * time.Second +) + +var ( + codexCredentialRefreshOnce sync.Once + codexCredentialRefreshRunning atomic.Bool +) + +func StartCodexCredentialAutoRefreshTask() { + codexCredentialRefreshOnce.Do(func() { + if !common.IsMasterNode { + return + } + + gopool.Go(func() { + logger.LogInfo(context.Background(), fmt.Sprintf("codex credential auto-refresh task started: tick=%s threshold=%s", codexCredentialRefreshTickInterval, codexCredentialRefreshThreshold)) + + ticker := time.NewTicker(codexCredentialRefreshTickInterval) + defer ticker.Stop() + + runCodexCredentialAutoRefreshOnce() + for range ticker.C { + runCodexCredentialAutoRefreshOnce() + } + }) + }) +} + +func runCodexCredentialAutoRefreshOnce() { + if !codexCredentialRefreshRunning.CompareAndSwap(false, true) { + return + } + defer codexCredentialRefreshRunning.Store(false) + + ctx := context.Background() + now := time.Now() + + var refreshed int + var scanned int + + offset := 0 + for { + var channels []*model.Channel + err := model.DB. + Select("id", "name", "key", "status", "channel_info"). + Where("type = ? AND status = 1", constant.ChannelTypeCodex). + Order("id asc"). + Limit(codexCredentialRefreshBatchSize). + Offset(offset). + Find(&channels).Error + if err != nil { + logger.LogError(ctx, fmt.Sprintf("codex credential auto-refresh: query channels failed: %v", err)) + return + } + if len(channels) == 0 { + break + } + offset += codexCredentialRefreshBatchSize + + for _, ch := range channels { + if ch == nil { + continue + } + scanned++ + if ch.ChannelInfo.IsMultiKey { + continue + } + + rawKey := strings.TrimSpace(ch.Key) + if rawKey == "" { + continue + } + + oauthKey, err := parseCodexOAuthKey(rawKey) + if err != nil { + continue + } + + refreshToken := strings.TrimSpace(oauthKey.RefreshToken) + if refreshToken == "" { + continue + } + + expiredAtRaw := strings.TrimSpace(oauthKey.Expired) + expiredAt, err := time.Parse(time.RFC3339, expiredAtRaw) + if err == nil && !expiredAt.IsZero() && expiredAt.Sub(now) > codexCredentialRefreshThreshold { + continue + } + + refreshCtx, cancel := context.WithTimeout(ctx, codexCredentialRefreshTimeout) + newKey, _, err := RefreshCodexChannelCredential(refreshCtx, ch.Id, CodexCredentialRefreshOptions{ResetCaches: false}) + cancel() + if err != nil { + logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v", ch.Id, ch.Name, err)) + continue + } + + refreshed++ + logger.LogInfo(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s", ch.Id, ch.Name, newKey.Expired)) + } + } + + if refreshed > 0 { + func() { + defer func() { + if r := recover(); r != nil { + logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: InitChannelCache panic: %v", r)) + } + }() + model.InitChannelCache() + }() + ResetProxyClientCache() + } + + if common.DebugEnabled { + logger.LogDebug(ctx, "codex credential auto-refresh: scanned=%d refreshed=%d", scanned, refreshed) + } +} diff --git a/service/codex_oauth.go b/service/codex_oauth.go new file mode 100644 index 00000000..4c2dce1c --- /dev/null +++ b/service/codex_oauth.go @@ -0,0 +1,288 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + codexOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + codexOAuthAuthorizeURL = "https://auth.openai.com/oauth/authorize" + codexOAuthTokenURL = "https://auth.openai.com/oauth/token" + codexOAuthRedirectURI = "http://localhost:1455/auth/callback" + codexOAuthScope = "openid profile email offline_access" + codexJWTClaimPath = "https://api.openai.com/auth" + defaultHTTPTimeout = 20 * time.Second +) + +type CodexOAuthTokenResult struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +type CodexOAuthAuthorizationFlow struct { + State string + Verifier string + Challenge string + AuthorizeURL string +} + +func RefreshCodexOAuthToken(ctx context.Context, refreshToken string) (*CodexOAuthTokenResult, error) { + client := &http.Client{Timeout: defaultHTTPTimeout} + return refreshCodexOAuthToken(ctx, client, codexOAuthTokenURL, codexOAuthClientID, refreshToken) +} + +func ExchangeCodexAuthorizationCode(ctx context.Context, code string, verifier string) (*CodexOAuthTokenResult, error) { + client := &http.Client{Timeout: defaultHTTPTimeout} + return exchangeCodexAuthorizationCode(ctx, client, codexOAuthTokenURL, codexOAuthClientID, code, verifier, codexOAuthRedirectURI) +} + +func CreateCodexOAuthAuthorizationFlow() (*CodexOAuthAuthorizationFlow, error) { + state, err := createStateHex(16) + if err != nil { + return nil, err + } + verifier, challenge, err := generatePKCEPair() + if err != nil { + return nil, err + } + u, err := buildCodexAuthorizeURL(state, challenge) + if err != nil { + return nil, err + } + return &CodexOAuthAuthorizationFlow{ + State: state, + Verifier: verifier, + Challenge: challenge, + AuthorizeURL: u, + }, nil +} + +func refreshCodexOAuthToken( + ctx context.Context, + client *http.Client, + tokenURL string, + clientID string, + refreshToken string, +) (*CodexOAuthTokenResult, error) { + rt := strings.TrimSpace(refreshToken) + if rt == "" { + return nil, errors.New("empty refresh_token") + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", rt) + form.Set("client_id", clientID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var payload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("codex oauth refresh failed: status=%d", resp.StatusCode) + } + + if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 { + return nil, errors.New("codex oauth refresh response missing fields") + } + + return &CodexOAuthTokenResult{ + AccessToken: strings.TrimSpace(payload.AccessToken), + RefreshToken: strings.TrimSpace(payload.RefreshToken), + ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second), + }, nil +} + +func exchangeCodexAuthorizationCode( + ctx context.Context, + client *http.Client, + tokenURL string, + clientID string, + code string, + verifier string, + redirectURI string, +) (*CodexOAuthTokenResult, error) { + c := strings.TrimSpace(code) + v := strings.TrimSpace(verifier) + if c == "" { + return nil, errors.New("empty authorization code") + } + if v == "" { + return nil, errors.New("empty code_verifier") + } + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", clientID) + form.Set("code", c) + form.Set("code_verifier", v) + form.Set("redirect_uri", redirectURI) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var payload struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("codex oauth code exchange failed: status=%d", resp.StatusCode) + } + if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 { + return nil, errors.New("codex oauth token response missing fields") + } + return &CodexOAuthTokenResult{ + AccessToken: strings.TrimSpace(payload.AccessToken), + RefreshToken: strings.TrimSpace(payload.RefreshToken), + ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second), + }, nil +} + +func buildCodexAuthorizeURL(state string, challenge string) (string, error) { + u, err := url.Parse(codexOAuthAuthorizeURL) + if err != nil { + return "", err + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", codexOAuthClientID) + q.Set("redirect_uri", codexOAuthRedirectURI) + q.Set("scope", codexOAuthScope) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", state) + q.Set("id_token_add_organizations", "true") + q.Set("codex_cli_simplified_flow", "true") + q.Set("originator", "codex_cli_rs") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func createStateHex(nBytes int) (string, error) { + if nBytes <= 0 { + return "", errors.New("invalid state bytes length") + } + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return fmt.Sprintf("%x", b), nil +} + +func generatePKCEPair() (verifier string, challenge string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(b) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func ExtractCodexAccountIDFromJWT(token string) (string, bool) { + claims, ok := decodeJWTClaims(token) + if !ok { + return "", false + } + raw, ok := claims[codexJWTClaimPath] + if !ok { + return "", false + } + obj, ok := raw.(map[string]any) + if !ok { + return "", false + } + v, ok := obj["chatgpt_account_id"] + if !ok { + return "", false + } + s, ok := v.(string) + if !ok { + return "", false + } + s = strings.TrimSpace(s) + if s == "" { + return "", false + } + return s, true +} + +func ExtractEmailFromJWT(token string) (string, bool) { + claims, ok := decodeJWTClaims(token) + if !ok { + return "", false + } + v, ok := claims["email"] + if !ok { + return "", false + } + s, ok := v.(string) + if !ok { + return "", false + } + s = strings.TrimSpace(s) + if s == "" { + return "", false + } + return s, true +} + +func decodeJWTClaims(token string) (map[string]any, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, false + } + payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, false + } + var claims map[string]any + if err := json.Unmarshal(payloadRaw, &claims); err != nil { + return nil, false + } + return claims, true +} diff --git a/service/codex_wham_usage.go b/service/codex_wham_usage.go new file mode 100644 index 00000000..d27cbd9d --- /dev/null +++ b/service/codex_wham_usage.go @@ -0,0 +1,56 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" +) + +func FetchCodexWhamUsage( + ctx context.Context, + client *http.Client, + baseURL string, + accessToken string, + accountID string, +) (statusCode int, body []byte, err error) { + if client == nil { + return 0, nil, fmt.Errorf("nil http client") + } + bu := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if bu == "" { + return 0, nil, fmt.Errorf("empty baseURL") + } + at := strings.TrimSpace(accessToken) + aid := strings.TrimSpace(accountID) + if at == "" { + return 0, nil, fmt.Errorf("empty accessToken") + } + if aid == "" { + return 0, nil, fmt.Errorf("empty accountID") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bu+"/backend-api/wham/usage", nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("Authorization", "Bearer "+at) + req.Header.Set("chatgpt-account-id", aid) + req.Header.Set("Accept", "application/json") + if req.Header.Get("originator") == "" { + req.Header.Set("originator", "codex_cli_rs") + } + + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + body, err = io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, err + } + return resp.StatusCode, body, nil +} diff --git a/service/openaicompat/chat_to_responses.go b/service/openaicompat/chat_to_responses.go index ddcc9f28..3779db93 100644 --- a/service/openaicompat/chat_to_responses.go +++ b/service/openaicompat/chat_to_responses.go @@ -54,6 +54,38 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d continue } + if role == "tool" || role == "function" { + callID := strings.TrimSpace(msg.ToolCallId) + + var output any + if msg.Content == nil { + output = "" + } else if msg.IsStringContent() { + output = msg.StringContent() + } else { + if b, err := common.Marshal(msg.Content); err == nil { + output = string(b) + } else { + output = fmt.Sprintf("%v", msg.Content) + } + } + + if callID == "" { + inputItems = append(inputItems, map[string]any{ + "role": "user", + "content": fmt.Sprintf("[tool_output_missing_call_id] %v", output), + }) + continue + } + + inputItems = append(inputItems, map[string]any{ + "type": "function_call_output", + "call_id": callID, + "output": output, + }) + continue + } + // Prefer mapping system/developer messages into `instructions`. if role == "system" || role == "developer" { if msg.Content == nil { @@ -88,12 +120,54 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d if msg.Content == nil { item["content"] = "" inputItems = append(inputItems, item) + + if role == "assistant" { + for _, tc := range msg.ParseToolCalls() { + if strings.TrimSpace(tc.ID) == "" { + continue + } + if tc.Type != "" && tc.Type != "function" { + continue + } + name := strings.TrimSpace(tc.Function.Name) + if name == "" { + continue + } + inputItems = append(inputItems, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": name, + "arguments": tc.Function.Arguments, + }) + } + } continue } if msg.IsStringContent() { item["content"] = msg.StringContent() inputItems = append(inputItems, item) + + if role == "assistant" { + for _, tc := range msg.ParseToolCalls() { + if strings.TrimSpace(tc.ID) == "" { + continue + } + if tc.Type != "" && tc.Type != "function" { + continue + } + name := strings.TrimSpace(tc.Function.Name) + if name == "" { + continue + } + inputItems = append(inputItems, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": name, + "arguments": tc.Function.Arguments, + }) + } + } continue } @@ -127,7 +201,6 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d "video_url": part.VideoUrl, }) default: - // Best-effort: keep unknown parts as-is to avoid silently dropping context. contentParts = append(contentParts, map[string]any{ "type": part.Type, }) @@ -135,6 +208,27 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d } item["content"] = contentParts inputItems = append(inputItems, item) + + if role == "assistant" { + for _, tc := range msg.ParseToolCalls() { + if strings.TrimSpace(tc.ID) == "" { + continue + } + if tc.Type != "" && tc.Type != "function" { + continue + } + name := strings.TrimSpace(tc.Function.Name) + if name == "" { + continue + } + inputItems = append(inputItems, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": name, + "arguments": tc.Function.Arguments, + }) + } + } } inputRaw, err := common.Marshal(inputItems) diff --git a/web/src/components/table/channels/modals/CodexOAuthModal.jsx b/web/src/components/table/channels/modals/CodexOAuthModal.jsx new file mode 100644 index 00000000..cdd4d768 --- /dev/null +++ b/web/src/components/table/channels/modals/CodexOAuthModal.jsx @@ -0,0 +1,151 @@ +/* +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, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui'; +import { API, copy, showError, showSuccess } from '../../../../helpers'; + +const { Text } = Typography; + +const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [authorizeUrl, setAuthorizeUrl] = useState(''); + const [input, setInput] = useState(''); + + const startOAuth = async () => { + setLoading(true); + try { + const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true }); + if (!res?.data?.success) { + console.error('Codex OAuth start failed:', res?.data?.message); + throw new Error(t('启动授权失败')); + } + const url = res?.data?.data?.authorize_url || ''; + if (!url) { + console.error('Codex OAuth start response missing authorize_url:', res?.data); + throw new Error(t('响应缺少授权链接')); + } + setAuthorizeUrl(url); + window.open(url, '_blank', 'noopener,noreferrer'); + showSuccess(t('已打开授权页面')); + } catch (error) { + showError(error?.message || t('启动授权失败')); + } finally { + setLoading(false); + } + }; + + const completeOAuth = async () => { + if (!input || !input.trim()) { + showError(t('请先粘贴回调 URL')); + return; + } + + setLoading(true); + try { + const res = await API.post( + '/api/channel/codex/oauth/complete', + { input }, + { skipErrorHandler: true }, + ); + if (!res?.data?.success) { + console.error('Codex OAuth complete failed:', res?.data?.message); + throw new Error(t('授权失败')); + } + + const key = res?.data?.data?.key || ''; + if (!key) { + console.error('Codex OAuth complete response missing key:', res?.data); + throw new Error(t('响应缺少凭据')); + } + + onSuccess && onSuccess(key); + showSuccess(t('已生成授权凭据')); + onCancel && onCancel(); + } catch (error) { + showError(error?.message || t('授权失败')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!visible) return; + setAuthorizeUrl(''); + setInput(''); + }, [visible]); + + return ( + + + + + } + > + + + + + + + + + setInput(value)} + placeholder={t('请粘贴完整回调 URL(包含 code 与 state)')} + showClear + /> + + + {t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')} + + + + ); +}; + +export default CodexOAuthModal; diff --git a/web/src/components/table/channels/modals/CodexUsageModal.jsx b/web/src/components/table/channels/modals/CodexUsageModal.jsx new file mode 100644 index 00000000..df5e2c98 --- /dev/null +++ b/web/src/components/table/channels/modals/CodexUsageModal.jsx @@ -0,0 +1,190 @@ +/* +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 from 'react'; +import { Modal, Button, Progress, Tag, Typography } from '@douyinfe/semi-ui'; + +const { Text } = Typography; + +const clampPercent = (value) => { + const v = Number(value); + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, v)); +}; + +const pickStrokeColor = (percent) => { + const p = clampPercent(percent); + if (p >= 95) return '#ef4444'; + if (p >= 80) return '#f59e0b'; + return '#3b82f6'; +}; + +const formatDurationSeconds = (seconds, t) => { + const tt = typeof t === 'function' ? t : (v) => v; + const s = Number(seconds); + if (!Number.isFinite(s) || s <= 0) return '-'; + const total = Math.floor(s); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`; + if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`; + return `${secs}${tt('秒')}`; +}; + +const formatUnixSeconds = (unixSeconds) => { + const v = Number(unixSeconds); + if (!Number.isFinite(v) || v <= 0) return '-'; + try { + return new Date(v * 1000).toLocaleString(); + } catch (error) { + return String(unixSeconds); + } +}; + +const RateLimitWindowCard = ({ t, title, windowData }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const percent = clampPercent(windowData?.used_percent ?? 0); + const resetAt = windowData?.reset_at; + const resetAfterSeconds = windowData?.reset_after_seconds; + const limitWindowSeconds = windowData?.limit_window_seconds; + + return ( +
+
+
{title}
+ + {tt('重置时间:')} + {formatUnixSeconds(resetAt)} + +
+ +
+ +
+ +
+
+ {tt('已使用:')} + {percent}% +
+
+ {tt('距离重置:')} + {formatDurationSeconds(resetAfterSeconds, tt)} +
+
+ {tt('窗口:')} + {formatDurationSeconds(limitWindowSeconds, tt)} +
+
+
+ ); +}; + +export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const data = payload?.data ?? null; + const rateLimit = data?.rate_limit ?? {}; + + const primary = rateLimit?.primary_window ?? null; + const secondary = rateLimit?.secondary_window ?? null; + + const allowed = !!rateLimit?.allowed; + const limitReached = !!rateLimit?.limit_reached; + const upstreamStatus = payload?.upstream_status; + + const statusTag = + allowed && !limitReached ? ( + {tt('可用')} + ) : ( + {tt('受限')} + ); + + const rawText = + typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2); + + Modal.info({ + title: ( +
+ {tt('Codex 用量')} + {statusTag} +
+ ), + centered: true, + width: 900, + style: { maxWidth: '95vw' }, + content: ( +
+
+ + {tt('渠道:')} + {record?.name || '-'} ({tt('编号:')} + {record?.id || '-'}) + + + {tt('上游状态码:')} + {upstreamStatus ?? '-'} + +
+ +
+ + +
+ +
+
+
{tt('原始 JSON')}
+ +
+
+            {rawText}
+          
+
+
+ ), + footer: ( +
+ +
+ ), + }); +}; diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 722c1e8a..0c813fc0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,6 +56,7 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import OllamaModelModal from './OllamaModelModal'; +import CodexOAuthModal from './CodexOAuthModal'; import JSONEditor from '../../../common/ui/JSONEditor'; import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; @@ -114,6 +115,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API,则直接输ApiKey'; case 51: return '按照如下格式输入: AccessKey|SecretAccessKey'; + case 57: + return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)'; default: return '请输入渠道对应的鉴权密钥'; } @@ -212,6 +215,9 @@ const EditChannelModal = (props) => { }, [inputs.model_mapping]); const [isIonetChannel, setIsIonetChannel] = useState(false); const [ionetMetadata, setIonetMetadata] = useState(null); + const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); + const [codexCredentialRefreshing, setCodexCredentialRefreshing] = + useState(false); // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ @@ -499,6 +505,18 @@ const EditChannelModal = (props) => { // 重置手动输入模式状态 setUseManualInput(false); + + if (value === 57) { + setBatch(false); + setMultiToSingle(false); + setMultiKeyMode('random'); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } + setInputs((prev) => ({ ...prev, vertex_files: [] })); + } } //setAutoBan }; @@ -822,6 +840,32 @@ const EditChannelModal = (props) => { } }; + const handleCodexOAuthGenerated = (key) => { + handleInputChange('key', key); + formatJsonField('key'); + }; + + const handleRefreshCodexCredential = async () => { + if (!isEdit) return; + + setCodexCredentialRefreshing(true); + try { + const res = await API.post( + `/api/channel/${channelId}/codex/refresh`, + {}, + { skipErrorHandler: true }, + ); + if (!res?.data?.success) { + throw new Error(res?.data?.message || 'Failed to refresh credential'); + } + showSuccess(t('凭证已刷新')); + } catch (error) { + showError(error.message || t('刷新失败')); + } finally { + setCodexCredentialRefreshing(false); + } + }; + useEffect(() => { if (inputs.type !== 45) { doubaoApiClickCountRef.current = 0; @@ -1070,6 +1114,47 @@ const EditChannelModal = (props) => { const formValues = formApiRef.current ? formApiRef.current.getValues() : {}; let localInputs = { ...formValues }; + if (localInputs.type === 57) { + if (batch) { + showInfo(t('Codex 渠道不支持批量创建')); + return; + } + + const rawKey = (localInputs.key || '').trim(); + if (!isEdit && rawKey === '') { + showInfo(t('请输入密钥!')); + return; + } + + if (rawKey !== '') { + if (!verifyJSON(rawKey)) { + showInfo(t('密钥必须是合法的 JSON 格式!')); + return; + } + try { + const parsed = JSON.parse(rawKey); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + showInfo(t('密钥必须是 JSON 对象')); + return; + } + const accessToken = String(parsed.access_token || '').trim(); + const accountId = String(parsed.account_id || '').trim(); + if (!accessToken) { + showInfo(t('密钥 JSON 必须包含 access_token')); + return; + } + if (!accountId) { + showInfo(t('密钥 JSON 必须包含 account_id')); + return; + } + localInputs.key = JSON.stringify(parsed); + } catch (error) { + showInfo(t('密钥必须是合法的 JSON 格式!')); + return; + } + } + } + if (localInputs.type === 41) { const keyType = localInputs.vertex_key_type || 'json'; if (keyType === 'api_key') { @@ -1401,7 +1486,7 @@ const EditChannelModal = (props) => { } }; - const batchAllowed = !isEdit || isMultiKeyChannel; + const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57; const batchExtra = batchAllowed ? ( {!isEdit && ( @@ -1884,8 +1969,94 @@ const EditChannelModal = (props) => { ) ) : ( <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( + {inputs.type === 57 ? ( + <> + handleInputChange('key', value)} + disabled={isIonetLocked} + extraText={ +
+ + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} + + + + + {isEdit && ( + + )} + + {isEdit && ( + + )} + {batchExtra} + +
+ } + autosize + showClear + /> + + setCodexOAuthModalVisible(false)} + onSuccess={handleCodexOAuthGenerated} + /> + + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( <> {!batch && (
diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 0d487958..ce2f6cd4 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -184,6 +184,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'Replicate', }, + { + value: 57, + color: 'blue', + label: 'Codex (OpenAI OAuth)', + }, ]; export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 4be02186..36aa7cbe 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -301,6 +301,7 @@ export function getChannelIcon(channelType) { switch (channelType) { case 1: // OpenAI case 3: // Azure OpenAI + case 57: // Codex return ; case 2: // Midjourney Proxy case 5: // Midjourney Proxy Plus diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 415a34a5..5e1feb16 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -36,6 +36,7 @@ import { import { useIsMobile } from '../common/useIsMobile'; import { useTableCompactMode } from '../common/useTableCompactMode'; import { Modal, Button } from '@douyinfe/semi-ui'; +import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal'; export const useChannelsData = () => { const { t } = useTranslation(); @@ -745,6 +746,32 @@ export const useChannelsData = () => { }; const updateChannelBalance = async (record) => { + if (record?.type === 57) { + try { + const res = await API.get(`/api/channel/${record.id}/codex/usage`, { + skipErrorHandler: true, + }); + if (!res?.data?.success) { + console.error('Codex usage fetch failed:', res?.data?.message); + showError(t('获取用量失败')); + } + openCodexUsageModal({ + t, + record, + payload: res?.data, + onCopy: async (text) => { + const ok = await copy(text); + if (ok) showSuccess(t('已复制')); + else showError(t('复制失败')); + }, + }); + } catch (error) { + console.error('Codex usage fetch error:', error); + showError(t('获取用量失败')); + } + return; + } + const res = await API.get(`/api/channel/update_balance/${record.id}/`); const { success, message, balance } = res.data; if (success) { From d694a197d22fb6e987fa635551003a3419182f5d Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 13:25:17 +0800 Subject: [PATCH 14/37] fix: openAI function to gemini function field adjusted to whitelist mode --- relay/channel/gemini/relay-gemini.go | 209 ++++++++++++++++++--------- 1 file changed, 138 insertions(+), 71 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 65e5aa98..24ff0121 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -655,102 +655,84 @@ func getSupportedMimeTypesList() []string { return keys } +var geminiOpenAPISchemaAllowedFields = map[string]struct{}{ + "anyOf": {}, + "default": {}, + "description": {}, + "enum": {}, + "example": {}, + "format": {}, + "items": {}, + "maxItems": {}, + "maxLength": {}, + "maxProperties": {}, + "maximum": {}, + "minItems": {}, + "minLength": {}, + "minProperties": {}, + "minimum": {}, + "nullable": {}, + "pattern": {}, + "properties": {}, + "propertyOrdering": {}, + "required": {}, + "title": {}, + "type": {}, +} + +const geminiFunctionSchemaMaxDepth = 64 + // cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters. func cleanFunctionParameters(params interface{}) interface{} { + return cleanFunctionParametersWithDepth(params, 0) +} + +func cleanFunctionParametersWithDepth(params interface{}, depth int) interface{} { if params == nil { return nil } + if depth >= geminiFunctionSchemaMaxDepth { + return cleanFunctionParametersShallow(params) + } + switch v := params.(type) { case map[string]interface{}: - // Create a copy to avoid modifying the original - cleanedMap := make(map[string]interface{}) + // Keep only Gemini-supported OpenAPI schema subset fields (per official SDK Schema). + cleanedMap := make(map[string]interface{}, len(v)) for k, val := range v { - cleanedMap[k] = val - } - - // Remove unsupported root-level fields - delete(cleanedMap, "default") - delete(cleanedMap, "exclusiveMaximum") - delete(cleanedMap, "exclusiveMinimum") - delete(cleanedMap, "$schema") - delete(cleanedMap, "additionalProperties") - delete(cleanedMap, "propertyNames") - - // Check and clean 'format' for string types - if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { - if formatValue, formatExists := cleanedMap["format"].(string); formatExists { - if formatValue != "enum" && formatValue != "date-time" { - delete(cleanedMap, "format") - } + if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok { + cleanedMap[k] = val } } + normalizeGeminiSchemaTypeAndNullable(cleanedMap) + // Clean properties if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { cleanedProps := make(map[string]interface{}) for propName, propValue := range props { - cleanedProps[propName] = cleanFunctionParameters(propValue) + cleanedProps[propName] = cleanFunctionParametersWithDepth(propValue, depth+1) } cleanedMap["properties"] = cleanedProps } // Recursively clean items in arrays if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { - cleanedMap["items"] = cleanFunctionParameters(items) + cleanedMap["items"] = cleanFunctionParametersWithDepth(items, depth+1) } - // Also handle items if it's an array of schemas - if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { - cleanedItemsArray := make([]interface{}, len(itemsArray)) - for i, item := range itemsArray { - cleanedItemsArray[i] = cleanFunctionParameters(item) - } - cleanedMap["items"] = cleanedItemsArray + // OpenAPI tuple-style items is not supported by Gemini SDK Schema; keep first to avoid API rejection. + if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 { + cleanedMap["items"] = cleanFunctionParametersWithDepth(itemsArray[0], depth+1) } - // Recursively clean other schema composition keywords - for _, field := range []string{"allOf", "anyOf", "oneOf"} { - if nested, ok := cleanedMap[field].([]interface{}); ok { - cleanedNested := make([]interface{}, len(nested)) - for i, item := range nested { - cleanedNested[i] = cleanFunctionParameters(item) - } - cleanedMap[field] = cleanedNested - } - } - - // Recursively clean patternProperties - if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok { - cleanedPatternProps := make(map[string]interface{}) - for pattern, schema := range patternProps { - cleanedPatternProps[pattern] = cleanFunctionParameters(schema) - } - cleanedMap["patternProperties"] = cleanedPatternProps - } - - // Recursively clean definitions - if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok { - cleanedDefinitions := make(map[string]interface{}) - for defName, defSchema := range definitions { - cleanedDefinitions[defName] = cleanFunctionParameters(defSchema) - } - cleanedMap["definitions"] = cleanedDefinitions - } - - // Recursively clean $defs (newer JSON Schema draft) - if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok { - cleanedDefs := make(map[string]interface{}) - for defName, defSchema := range defs { - cleanedDefs[defName] = cleanFunctionParameters(defSchema) - } - cleanedMap["$defs"] = cleanedDefs - } - - // Clean conditional keywords - for _, field := range []string{"if", "then", "else", "not"} { - if nested, ok := cleanedMap[field]; ok { - cleanedMap[field] = cleanFunctionParameters(nested) + // Recursively clean anyOf + if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil { + cleanedNested := make([]interface{}, len(nested)) + for i, item := range nested { + cleanedNested[i] = cleanFunctionParametersWithDepth(item, depth+1) } + cleanedMap["anyOf"] = cleanedNested } return cleanedMap @@ -759,7 +741,7 @@ func cleanFunctionParameters(params interface{}) interface{} { // Handle arrays of schemas cleanedArray := make([]interface{}, len(v)) for i, item := range v { - cleanedArray[i] = cleanFunctionParameters(item) + cleanedArray[i] = cleanFunctionParametersWithDepth(item, depth+1) } return cleanedArray @@ -769,6 +751,91 @@ func cleanFunctionParameters(params interface{}) interface{} { } } +func cleanFunctionParametersShallow(params interface{}) interface{} { + switch v := params.(type) { + case map[string]interface{}: + cleanedMap := make(map[string]interface{}, len(v)) + for k, val := range v { + if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok { + cleanedMap[k] = val + } + } + normalizeGeminiSchemaTypeAndNullable(cleanedMap) + // Stop recursion and avoid retaining huge nested structures. + delete(cleanedMap, "properties") + delete(cleanedMap, "items") + delete(cleanedMap, "anyOf") + return cleanedMap + case []interface{}: + // Prefer an empty list over deep recursion on attacker-controlled inputs. + return []interface{}{} + default: + return params + } +} + +func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) { + rawType, ok := schema["type"] + if !ok || rawType == nil { + return + } + + normalize := func(t string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(t)) { + case "object": + return "OBJECT", false + case "array": + return "ARRAY", false + case "string": + return "STRING", false + case "integer": + return "INTEGER", false + case "number": + return "NUMBER", false + case "boolean": + return "BOOLEAN", false + case "null": + return "", true + default: + return t, false + } + } + + switch t := rawType.(type) { + case string: + normalized, isNull := normalize(t) + if isNull { + schema["nullable"] = true + delete(schema, "type") + return + } + schema["type"] = normalized + case []interface{}: + nullable := false + var chosen string + for _, item := range t { + if s, ok := item.(string); ok { + normalized, isNull := normalize(s) + if isNull { + nullable = true + continue + } + if chosen == "" { + chosen = normalized + } + } + } + if nullable { + schema["nullable"] = true + } + if chosen != "" { + schema["type"] = chosen + } else { + delete(schema, "type") + } + } +} + func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} { if depth >= 5 { return schema From 980af061fd22bd3ea0c6d0b02ab9134609f05742 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 14:43:53 +0800 Subject: [PATCH 15/37] feat: TLS_INSECURE_SKIP_VERIFY env --- .env.example | 3 +++ common/constants.go | 4 ++++ common/init.go | 11 +++++++++++ controller/model_sync.go | 17 +++++++++++++++-- controller/ratio_sync.go | 4 ++++ service/http_client.go | 39 ++++++++++++++++++++++++--------------- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index ea9061fb..f4b9d02e 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,9 @@ # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 # STREAMING_TIMEOUT=300 +# TLS / HTTP 跳过验证设置 +# TLS_INSECURE_SKIP_VERIFY=false + # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 diff --git a/common/constants.go b/common/constants.go index e33a64b2..51b798db 100644 --- a/common/constants.go +++ b/common/constants.go @@ -1,6 +1,7 @@ package common import ( + "crypto/tls" //"os" //"strconv" "sync" @@ -73,6 +74,9 @@ var MemoryCacheEnabled bool var LogConsumeEnabled = true +var TLSInsecureSkipVerify bool +var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true} + var SMTPServer = "" var SMTPPort = 587 var SMTPSSLEnabled = false diff --git a/common/init.go b/common/init.go index 0789f8cc..9501ce3b 100644 --- a/common/init.go +++ b/common/init.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "net/http" "os" "path/filepath" "strconv" @@ -81,6 +82,16 @@ func InitEnv() { DebugEnabled = os.Getenv("DEBUG") == "true" MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" IsMasterNode = os.Getenv("NODE_TYPE") != "slave" + TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false) + if TLSInsecureSkipVerify { + if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil { + if tr.TLSClientConfig != nil { + tr.TLSClientConfig.InsecureSkipVerify = true + } else { + tr.TLSClientConfig = InsecureTLSConfig + } + } + } // Parse requestInterval and set RequestInterval requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) diff --git a/controller/model_sync.go b/controller/model_sync.go index b2ac99da..737f92d4 100644 --- a/controller/model_sync.go +++ b/controller/model_sync.go @@ -99,6 +99,9 @@ func newHTTPClient() *http.Client { ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second, } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, _, err := net.SplitHostPort(addr) if err != nil { @@ -115,7 +118,17 @@ func newHTTPClient() *http.Client { return &http.Client{Transport: transport} } -var httpClient = newHTTPClient() +var ( + httpClientOnce sync.Once + httpClient *http.Client +) + +func getHTTPClient() *http.Client { + httpClientOnce.Do(func() { + httpClient = newHTTPClient() + }) + return httpClient +} func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { var lastErr error @@ -138,7 +151,7 @@ func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) } cacheMutex.RUnlock() - resp, err := httpClient.Do(req) + resp, err := getHTTPClient().Do(req) if err != nil { lastErr = err // backoff with jitter diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index b8224b81..0b6a6dff 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/dto" @@ -110,6 +111,9 @@ func FetchUpstreamRatios(c *gin.Context) { dialer := &net.Dialer{Timeout: 10 * time.Second} transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { host, _, err := net.SplitHostPort(addr) if err != nil { diff --git a/service/http_client.go b/service/http_client.go index 783aac89..2c3168f2 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -40,6 +40,9 @@ func InitHttpClient() { ForceAttemptHTTP2: true, Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } if common.RelayTimeout == 0 { httpClient = &http.Client{ @@ -102,13 +105,17 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { switch parsedURL.Scheme { case "http", "https": + transport := &http.Transport{ + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyURL(parsedURL), + } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } client := &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), - }, + Transport: transport, CheckRedirect: checkRedirect, } client.Timeout = time.Duration(common.RelayTimeout) * time.Second @@ -137,17 +144,19 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { return nil, err } - client := &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - ForceAttemptHTTP2: true, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialer.Dial(network, addr) - }, + transport := &http.Transport{ + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) }, - CheckRedirect: checkRedirect, } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } + + client := &http.Client{Transport: transport, CheckRedirect: checkRedirect} client.Timeout = time.Duration(common.RelayTimeout) * time.Second proxyClientLock.Lock() proxyClients[proxyURL] = client From f9c7daedcf1de02bb5f038daeeec0ccadcac2e52 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 15:28:02 +0800 Subject: [PATCH 16/37] fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. --- relay/compatible_handler.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 6c36f83d..1a534f58 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -335,6 +335,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage var audioInputQuota decimal.Decimal var audioInputPrice float64 + isClaudeUsageSemantic := relayInfo.ChannelType == constant.ChannelTypeAnthropic if !relayInfo.PriceData.UsePrice { baseTokens := dPromptTokens // 减去 cached tokens @@ -342,14 +343,14 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage // OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens,需要减去 var cachedTokensWithRatio decimal.Decimal if !dCacheTokens.IsZero() { - if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + if !isClaudeUsageSemantic { baseTokens = baseTokens.Sub(dCacheTokens) } cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } var dCachedCreationTokensWithRatio decimal.Decimal if !dCachedCreationTokens.IsZero() { - if relayInfo.ChannelType != constant.ChannelTypeAnthropic { + if !isClaudeUsageSemantic { baseTokens = baseTokens.Sub(dCachedCreationTokens) } dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) @@ -459,6 +460,11 @@ 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) + // 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 + other["usage_semantic"] = "anthropic" + } if imageTokens != 0 { other["image"] = true other["image_ratio"] = imageRatio From eb828f63025553c92a773219b8483d4a2c77fa6b Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 15 Jan 2026 23:19:51 +0800 Subject: [PATCH 17/37] fix: the login method cannot be displayed under the aff link. --- web/src/components/auth/LoginForm.jsx | 22 +++++++++++++------ web/src/components/auth/RegisterForm.jsx | 28 ++++++++++++++---------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index d87fc349..5111f1f6 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -17,9 +17,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; import { API, getLogo, @@ -73,6 +74,7 @@ const LoginForm = () => { const [searchParams, setSearchParams] = useSearchParams(); const [submitted, setSubmitted] = useState(false); const [userState, userDispatch] = useContext(UserContext); + const [statusState] = useContext(StatusContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); @@ -108,20 +110,26 @@ const LoginForm = () => { localStorage.setItem('aff', affCode); } - const [status] = useState(() => { + const status = useMemo(() => { + if (statusState?.status) return statusState.status; const savedStatus = localStorage.getItem('status'); - return savedStatus ? JSON.parse(savedStatus) : {}; - }); + if (!savedStatus) return {}; + try { + return JSON.parse(savedStatus) || {}; + } catch (err) { + return {}; + } + }, [statusState?.status]); useEffect(() => { - if (status.turnstile_check) { + if (status?.turnstile_check) { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status.user_agreement_enabled || false); - setHasPrivacyPolicy(status.privacy_policy_enabled || false); + setHasUserAgreement(status?.user_agreement_enabled || false); + setHasPrivacyPolicy(status?.privacy_policy_enabled || false); }, [status]); useEffect(() => { diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6dabb516..7bdc40c6 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { API, @@ -51,6 +51,7 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User'; +import { StatusContext } from '../../context/Status'; import { useTranslation } from 'react-i18next'; import { SiDiscord } from 'react-icons/si'; @@ -72,6 +73,7 @@ const RegisterForm = () => { }); const { username, password, password2 } = inputs; const [userState, userDispatch] = useContext(UserContext); + const [statusState] = useContext(StatusContext); const [turnstileEnabled, setTurnstileEnabled] = useState(false); const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); @@ -106,25 +108,29 @@ const RegisterForm = () => { localStorage.setItem('aff', affCode); } - const [status] = useState(() => { + const status = useMemo(() => { + if (statusState?.status) return statusState.status; const savedStatus = localStorage.getItem('status'); - return savedStatus ? JSON.parse(savedStatus) : {}; - }); + if (!savedStatus) return {}; + try { + return JSON.parse(savedStatus) || {}; + } catch (err) { + return {}; + } + }, [statusState?.status]); - const [showEmailVerification, setShowEmailVerification] = useState(() => { - return status.email_verification ?? false; - }); + const [showEmailVerification, setShowEmailVerification] = useState(false); useEffect(() => { - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { + setShowEmailVerification(!!status?.email_verification); + if (status?.turnstile_check) { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } // 从 status 获取用户协议和隐私政策的启用状态 - setHasUserAgreement(status.user_agreement_enabled || false); - setHasPrivacyPolicy(status.privacy_policy_enabled || false); + setHasUserAgreement(status?.user_agreement_enabled || false); + setHasPrivacyPolicy(status?.privacy_policy_enabled || false); }, [status]); useEffect(() => { From 19439414d0a7db4072b197443a889970010d4868 Mon Sep 17 00:00:00 2001 From: Seefs Date: Sat, 17 Jan 2026 21:42:28 +0800 Subject: [PATCH 18/37] fix: codex Unsupported parameter: max_output_tokens --- relay/channel/codex/adaptor.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 92d855a5..76b7d073 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -91,6 +91,8 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo // codex: store must be false request.Store = json.RawMessage("false") + // rm max_output_tokens + request.MaxOutputTokens = 0 return request, nil } From d95f334c6320ded900836ff31d8b4364c4dc00e3 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 10:47:55 +0800 Subject: [PATCH 19/37] fix: jimeng i2v support multi image by metadata --- relay/channel/task/jimeng/adaptor.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go index 91d3f236..1522a967 100644 --- a/relay/channel/task/jimeng/adaptor.go +++ b/relay/channel/task/jimeng/adaptor.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" + "github.com/samber/lo" "github.com/gin-gonic/gin" "github.com/pkg/errors" @@ -409,14 +410,15 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* // 即梦视频3.0 ReqKey转换 // https://www.volcengine.com/docs/85621/1792707 + imageLen := lo.Max([]int{len(req.Images), len(r.BinaryDataBase64), len(r.ImageUrls)}) if strings.Contains(r.ReqKey, "jimeng_v30") { if r.ReqKey == "jimeng_v30_pro" { // 3.0 pro只有固定的jimeng_ti2v_v30_pro r.ReqKey = "jimeng_ti2v_v30_pro" - } else if len(req.Images) > 1 { + } else if imageLen > 1 { // 多张图片:首尾帧生成 r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1), "p") - } else if len(req.Images) == 1 { + } else if imageLen == 1 { // 单张图片:图生视频 r.ReqKey = strings.TrimSuffix(strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1), "p") } else { From 0f8ba448cef58dd0c7a2444d7506bedb56c3a2d1 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 12:57:51 +0800 Subject: [PATCH 20/37] fix: update warning threshold label from '5$' to '2$' --- .../components/settings/personal/cards/NotificationSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 0c51d239..d658bd7a 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -440,7 +440,7 @@ const NotificationSettings = ({ data={[ { value: 100000, label: '0.2$' }, { value: 500000, label: '1$' }, - { value: 1000000, label: '5$' }, + { value: 1000000, label: '2$' }, { value: 5000000, label: '10$' }, ]} onChange={(val) => handleFormChange('warningThreshold', val)} From 5d8026c5394c018cdbcb328b137949ebf7eaf99c Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Mon, 19 Jan 2026 13:59:51 +0800 Subject: [PATCH 21/37] fix: video content api Priority use url field --- controller/video_proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index f102baae..4815394a 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -12,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/samber/lo" "github.com/gin-gonic/gin" ) @@ -134,8 +135,7 @@ func VideoProxy(c *gin.Context) { videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID) req.Header.Set("Authorization", "Bearer "+channel.Key) default: - // Video URL is directly in task.FailReason - videoURL = task.FailReason + videoURL = lo.Ternary(task.Url != "", task.Url, task.FailReason) } req.URL, err = url.Parse(videoURL) From b311d482e3ce4459d7fe40df54ae757dd6301a53 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 19 Jan 2026 17:35:22 +0800 Subject: [PATCH 22/37] fix: update abortWithOpenAiMessage function to use types.ErrorCode --- middleware/auth.go | 5 +++-- middleware/distributor.go | 4 ++-- middleware/utils.go | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/middleware/auth.go b/middleware/auth.go index 85c46e28..a5d283d2 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -195,7 +196,7 @@ func TokenAuth() func(c *gin.Context) { } c.Request.Header.Set("Authorization", "Bearer "+key) } - // 检查path包含/v1/messages 或 /v1/models + // 检查path包含/v1/messages 或 /v1/models if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") { anthropicKey := c.Request.Header.Get("x-api-key") if anthropicKey != "" { @@ -256,7 +257,7 @@ func TokenAuth() func(c *gin.Context) { return } if common.IsIpInCIDRList(ip, allowIps) == false { - abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中", types.ErrorCodeAccessDenied) return } logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp) diff --git a/middleware/distributor.go b/middleware/distributor.go index a3340472..95fa64a3 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -114,11 +114,11 @@ func Distribute() func(c *gin.Context) { // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) // message = "数据库一致性已被破坏,请联系管理员" //} - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound) return } if channel == nil { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound)) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound) return } } diff --git a/middleware/utils.go b/middleware/utils.go index 24caa83c..f198af81 100644 --- a/middleware/utils.go +++ b/middleware/utils.go @@ -5,13 +5,14 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) -func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) { +func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...types.ErrorCode) { codeStr := "" if len(code) > 0 { - codeStr = code[0] + codeStr = string(code[0]) } userId := c.GetInt("id") c.JSON(statusCode, gin.H{ From ca56618d9520802e93c66b0223d5ff546818dcd8 Mon Sep 17 00:00:00 2001 From: daggeryu <997411652@qq.com> Date: Tue, 20 Jan 2026 10:08:56 +0800 Subject: [PATCH 23/37] =?UTF-8?q?fix=C2=A0request=20pass-through=20aws=20c?= =?UTF-8?q?hannels=20can't=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common.GetRequestBody(c) read bod is null --- controller/channel-test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index 2ae8b0ef..8ebfbdf6 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -332,7 +332,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) } requestBody := bytes.NewBuffer(jsonData) - c.Request.Body = io.NopCloser(requestBody) + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) resp, err := adaptor.DoRequest(c, info, requestBody) if err != nil { return testResult{ From 61087f7a8329de46454d2905be056f1bda1bb4ce Mon Sep 17 00:00:00 2001 From: Bliod Date: Tue, 20 Jan 2026 04:29:56 +0000 Subject: [PATCH 24/37] fix: fix email send --- web/src/components/auth/RegisterForm.jsx | 28 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6dabb516..5f60a5f1 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -31,7 +31,15 @@ import { onDiscordOAuthClicked, } from '../../helpers'; import Turnstile from 'react-turnstile'; -import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; +import { + Button, + Card, + Checkbox, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import { @@ -121,7 +129,7 @@ const RegisterForm = () => { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); } - + // 从 status 获取用户协议和隐私政策的启用状态 setHasUserAgreement(status.user_agreement_enabled || false); setHasPrivacyPolicy(status.privacy_policy_enabled || false); @@ -235,7 +243,7 @@ const RegisterForm = () => { setVerificationCodeLoading(true); try { const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + `/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`, ); const { success, message } = res.data; if (success) { @@ -405,7 +413,15 @@ const RegisterForm = () => { theme='outline' className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors' type='tertiary' - icon={} + icon={ + + } onClick={handleDiscordClick} loading={discordLoading} > @@ -619,7 +635,9 @@ const RegisterForm = () => { htmlType='submit' onClick={handleSubmit} loading={registerLoading} - disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms} + disabled={ + (hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms + } > {t('注册')} From 7e90c832e2c2adaacd0e7ac464d98775dfebc476 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:03:19 +0800 Subject: [PATCH 25/37] fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 --- relay/channel/gemini/relay-gemini.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 65e5aa98..c76b5c24 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1141,6 +1141,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * id := helper.GetResponseID(c) createAt := common.GetTimestamp() finishReason := constant.FinishReasonStop + toolCallIndexByChoice := make(map[int]map[string]int) + nextToolCallIndexByChoice := make(map[int]int) usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) @@ -1148,6 +1150,28 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp * response.Id = id response.Created = createAt response.Model = info.UpstreamModelName + for choiceIdx := range response.Choices { + choiceKey := response.Choices[choiceIdx].Index + for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls { + tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx] + if tool.ID == "" { + continue + } + m := toolCallIndexByChoice[choiceKey] + if m == nil { + m = make(map[string]int) + toolCallIndexByChoice[choiceKey] = m + } + if idx, ok := m[tool.ID]; ok { + tool.SetIndex(idx) + continue + } + idx := nextToolCallIndexByChoice[choiceKey] + nextToolCallIndexByChoice[choiceKey] = idx + 1 + m[tool.ID] = idx + tool.SetIndex(idx) + } + } logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount)) if info.SendResponseCount == 0 { From 4fe84bad86c84f7966747d514ee40147ebef8cd2 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:36:36 +0800 Subject: [PATCH 26/37] fix: replace Alibaba's Claude-compatible interface with the new interface --- relay/channel/ali/adaptor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 751a4538..50fe1690 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -53,7 +53,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string switch info.RelayFormat { case types.RelayFormatClaude: - fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.ChannelBaseUrl) + fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) default: switch info.RelayMode { case constant.RelayModeEmbeddings: From 40f2b2fd68609df5610d5ce92f5700a3020d7068 Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 22:56:02 +0800 Subject: [PATCH 27/37] fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. --- relay/channel/ali/adaptor.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 50fe1690..d9108c6a 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -22,6 +23,11 @@ type Adaptor struct { IsSyncImageModel bool } +func supportsAliAnthropicMessages(modelName string) bool { + // Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. + return strings.Contains(strings.ToLower(modelName), "qwen") +} + var syncModels = []string{ "z-image", "qwen-image", @@ -43,7 +49,18 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt } func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { - return req, nil + if supportsAliAnthropicMessages(info.UpstreamModelName) { + return req, nil + } + + oaiReq, err := service.ClaudeToOpenAIRequest(*req, info) + if err != nil { + return nil, err + } + if info.SupportStreamOptions && info.IsStream { + oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true} + } + return a.ConvertOpenAIRequest(c, info, oaiReq) } func (a *Adaptor) Init(info *relaycommon.RelayInfo) { @@ -53,7 +70,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { var fullRequestURL string switch info.RelayFormat { case types.RelayFormatClaude: - fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) + if supportsAliAnthropicMessages(info.UpstreamModelName) { + fullRequestURL = fmt.Sprintf("%s/apps/anthropic/v1/messages", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + } default: switch info.RelayMode { case constant.RelayModeEmbeddings: @@ -197,11 +218,16 @@ 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.RelayFormat { case types.RelayFormatClaude: - if info.IsStream { - return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) - } else { + if supportsAliAnthropicMessages(info.UpstreamModelName) { + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) } + + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) default: switch info.RelayMode { case constant.RelayModeImagesGenerations: From 38791fa46da523ce22289046539e9c198675c92e Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 20 Jan 2026 23:43:29 +0800 Subject: [PATCH 28/37] feat: log shows request conversion --- model/log.go | 3 +- relay/chat_completions_via_responses.go | 2 + relay/claude_handler.go | 1 + relay/common/relay_info.go | 77 +++++++++++++++---- relay/common/request_conversion.go | 40 ++++++++++ relay/compatible_handler.go | 1 + relay/embedding_handler.go | 1 + relay/gemini_handler.go | 1 + relay/image_handler.go | 1 + relay/rerank_handler.go | 1 + relay/responses_handler.go | 1 + service/log_info_generate.go | 21 +++++ web/src/hooks/usage-logs/useUsageLogsData.jsx | 13 +++- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + 15 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 relay/common/request_conversion.go diff --git a/model/log.go b/model/log.go index 7495d647..f8940c15 100644 --- a/model/log.go +++ b/model/log.go @@ -56,8 +56,9 @@ func formatUserLogs(logs []*Log) { var otherMap map[string]interface{} otherMap, _ = common.StrToMap(logs[i].Other) if otherMap != nil { - // delete admin + // Remove admin-only debug fields. delete(otherMap, "admin_info") + delete(otherMap, "request_conversion") } logs[i].Other = common.MapToJsonStr(otherMap) logs[i].Id = logs[i].Id % 1024 diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 4b369440..38dae3c5 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -97,6 +97,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } + info.AppendRequestConversion(types.RelayFormatOpenAIResponses) savedRelayMode := info.RelayMode savedRequestURLPath := info.RequestURLPath @@ -112,6 +113,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad if err != nil { return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7a18c173..7e05116d 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -110,6 +110,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 4665573d..5c24ce57 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -121,6 +121,10 @@ type RelayInfo struct { Request dto.Request + // RequestConversionChain records request format conversions in order, e.g. + // ["openai", "openai_responses"] or ["openai", "claude"]. + RequestConversionChain []types.RelayFormat + ThinkingContentInfo TokenCountMeta *ClaudeConvertInfo @@ -448,38 +452,83 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { } func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { + var info *RelayInfo + var err error switch relayFormat { case types.RelayFormatOpenAI: - return GenRelayInfoOpenAI(c, request), nil + info = GenRelayInfoOpenAI(c, request) case types.RelayFormatOpenAIAudio: - return GenRelayInfoOpenAIAudio(c, request), nil + info = GenRelayInfoOpenAIAudio(c, request) case types.RelayFormatOpenAIImage: - return GenRelayInfoImage(c, request), nil + info = GenRelayInfoImage(c, request) case types.RelayFormatOpenAIRealtime: - return GenRelayInfoWs(c, ws), nil + info = GenRelayInfoWs(c, ws) case types.RelayFormatClaude: - return GenRelayInfoClaude(c, request), nil + info = GenRelayInfoClaude(c, request) case types.RelayFormatRerank: if request, ok := request.(*dto.RerankRequest); ok { - return GenRelayInfoRerank(c, request), nil + info = GenRelayInfoRerank(c, request) + break } - return nil, errors.New("request is not a RerankRequest") + err = errors.New("request is not a RerankRequest") case types.RelayFormatGemini: - return GenRelayInfoGemini(c, request), nil + info = GenRelayInfoGemini(c, request) case types.RelayFormatEmbedding: - return GenRelayInfoEmbedding(c, request), nil + info = GenRelayInfoEmbedding(c, request) case types.RelayFormatOpenAIResponses: if request, ok := request.(*dto.OpenAIResponsesRequest); ok { - return GenRelayInfoResponses(c, request), nil + info = GenRelayInfoResponses(c, request) + break } - return nil, errors.New("request is not a OpenAIResponsesRequest") + err = errors.New("request is not a OpenAIResponsesRequest") case types.RelayFormatTask: - return genBaseRelayInfo(c, nil), nil + info = genBaseRelayInfo(c, nil) case types.RelayFormatMjProxy: - return genBaseRelayInfo(c, nil), nil + info = genBaseRelayInfo(c, nil) default: - return nil, errors.New("invalid relay format") + err = errors.New("invalid relay format") } + + if err != nil { + return nil, err + } + if info == nil { + return nil, errors.New("failed to build relay info") + } + + info.InitRequestConversionChain() + return info, nil +} + +func (info *RelayInfo) InitRequestConversionChain() { + if info == nil { + return + } + if len(info.RequestConversionChain) > 0 { + return + } + if info.RelayFormat == "" { + return + } + info.RequestConversionChain = []types.RelayFormat{info.RelayFormat} +} + +func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) { + if info == nil { + return + } + if format == "" { + return + } + if len(info.RequestConversionChain) == 0 { + info.RequestConversionChain = []types.RelayFormat{format} + return + } + last := info.RequestConversionChain[len(info.RequestConversionChain)-1] + if last == format { + return + } + info.RequestConversionChain = append(info.RequestConversionChain, format) } //func (info *RelayInfo) SetPromptTokens(promptTokens int) { diff --git a/relay/common/request_conversion.go b/relay/common/request_conversion.go new file mode 100644 index 00000000..96b728d2 --- /dev/null +++ b/relay/common/request_conversion.go @@ -0,0 +1,40 @@ +package common + +import ( + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" +) + +func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) { + switch req.(type) { + case *dto.GeneralOpenAIRequest, dto.GeneralOpenAIRequest: + return types.RelayFormatOpenAI, true + case *dto.OpenAIResponsesRequest, dto.OpenAIResponsesRequest: + return types.RelayFormatOpenAIResponses, true + case *dto.ClaudeRequest, dto.ClaudeRequest: + return types.RelayFormatClaude, true + case *dto.GeminiChatRequest, dto.GeminiChatRequest: + return types.RelayFormatGemini, true + case *dto.EmbeddingRequest, dto.EmbeddingRequest: + return types.RelayFormatEmbedding, true + case *dto.RerankRequest, dto.RerankRequest: + return types.RelayFormatRerank, true + case *dto.ImageRequest, dto.ImageRequest: + return types.RelayFormatOpenAIImage, true + case *dto.AudioRequest, dto.AudioRequest: + return types.RelayFormatOpenAIAudio, true + default: + return "", false + } +} + +func AppendRequestConversionFromRequest(info *RelayInfo, req any) { + if info == nil { + return + } + format, ok := GuessRelayFormatFromRequest(req) + if !ok { + return + } + info.AppendRequestConversion(format) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 1a534f58..eab5052d 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -113,6 +113,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) if info.ChannelSetting.SystemPrompt != "" { // 如果有系统提示,则将其添加到请求中 diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go index 2cedf02b..1a41756b 100644 --- a/relay/embedding_handler.go +++ b/relay/embedding_handler.go @@ -45,6 +45,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := json.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 79ffba51..779670b9 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -149,6 +149,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/image_handler.go b/relay/image_handler.go index f110f4e8..1ee790b7 100644 --- a/relay/image_handler.go +++ b/relay/image_handler.go @@ -57,6 +57,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) switch convertedRequest.(type) { case *bytes.Buffer: diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go index 9a50fd27..35c66a29 100644 --- a/relay/rerank_handler.go +++ b/relay/rerank_handler.go @@ -53,6 +53,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 5c3d9a42..769437a1 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -53,6 +53,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) jsonData, err := common.Marshal(convertedRequest) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 1bd7df67..8018396d 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -70,9 +70,30 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other["admin_info"] = adminInfo appendRequestPath(ctx, relayInfo, other) + appendRequestConversionChain(relayInfo, other) return other } +func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { + if relayInfo == nil || other == nil { + return + } + if len(relayInfo.RequestConversionChain) == 0 { + return + } + chain := make([]string, 0, len(relayInfo.RequestConversionChain)) + for _, f := range relayInfo.RequestConversionChain { + if f == "" { + continue + } + chain = append(chain, string(f)) + } + if len(chain) == 0 { + return + } + other["request_conversion"] = chain +} + func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) info["ws"] = true diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 2d0ed324..18a8dbc7 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -476,10 +476,17 @@ export const useLogsData = () => { }); } } - if (other?.request_path) { + if (isAdminUser) { + const requestConversionChain = other?.request_conversion; + const chain = Array.isArray(requestConversionChain) + ? requestConversionChain.filter(Boolean) + : []; expandDataLocal.push({ - key: t('请求路径'), - value: other.request_path, + key: t('请求转换'), + value: + chain.length > 1 + ? `${chain.join(' -> ')}` + : t('原生格式'), }); } if (isAdminUser) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6d55544..a49598bf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2091,6 +2091,9 @@ "请求结束后多退少补": "Adjust after request completion", "请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login", "请求路径": "Request path", + "请求转换": "Request conversion", + "原生格式": "Native format", + "转换": "Convert", "请求预扣费额度": "Pre-deduction quota for requests", "请点击我": "Please click me", "请确认以下设置信息,点击\"初始化系统\"开始配置": "Please confirm the following settings information, click \"Initialize system\" to start configuration", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e91f50a4..e7579c59 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2077,6 +2077,9 @@ "请求结束后多退少补": "请求结束后多退少补", "请求超时,请刷新页面后重新发起 GitHub 登录": "请求超时,请刷新页面后重新发起 GitHub 登录", "请求路径": "请求路径", + "请求转换": "请求转换", + "原生格式": "原生格式", + "转换": "转换", "请求预扣费额度": "请求预扣费额度", "请点击我": "请点击我", "请确认以下设置信息,点击\"初始化系统\"开始配置": "请确认以下设置信息,点击\"初始化系统\"开始配置", From 1b1c987dfeec6a66abc35028ff57fab46221c89c Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:01:36 +0800 Subject: [PATCH 29/37] feat: optimized display --- service/log_info_generate.go | 14 +++++++++++--- web/src/hooks/usage-logs/useUsageLogsData.jsx | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 8018396d..71a6bd32 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -83,10 +83,18 @@ func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[st } chain := make([]string, 0, len(relayInfo.RequestConversionChain)) for _, f := range relayInfo.RequestConversionChain { - if f == "" { - continue + switch f { + case types.RelayFormatOpenAI: + chain = append(chain, "OpenAI Compatible") + case types.RelayFormatClaude: + chain = append(chain, "Claude Messages") + case types.RelayFormatGemini: + chain = append(chain, "Google Gemini") + case types.RelayFormatOpenAIResponses: + chain = append(chain, "OpenAI Responses") + default: + chain = append(chain, string(f)) } - chain = append(chain, string(f)) } if len(chain) == 0 { return diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 18a8dbc7..f2bc7988 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -306,6 +306,16 @@ export const useLogsData = () => { // Format logs data const setLogsFormat = (logs) => { + const requestConversionDisplayValue = (conversionChain) => { + const chain = Array.isArray(conversionChain) + ? conversionChain.filter(Boolean) + : []; + if (chain.length <= 1) { + return t('原生格式'); + } + return chain.join(' -> '); + }; + let expandDatesLocal = {}; for (let i = 0; i < logs.length; i++) { logs[i].timestamp2string = timestamp2string(logs[i].created_at); @@ -477,16 +487,9 @@ export const useLogsData = () => { } } if (isAdminUser) { - const requestConversionChain = other?.request_conversion; - const chain = Array.isArray(requestConversionChain) - ? requestConversionChain.filter(Boolean) - : []; expandDataLocal.push({ key: t('请求转换'), - value: - chain.length > 1 - ? `${chain.join(' -> ')}` - : t('原生格式'), + value: requestConversionDisplayValue(other?.request_conversion), }); } if (isAdminUser) { From 0d5fe4bfe97488e50b46c29505dfa965a378034e Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:12:41 +0800 Subject: [PATCH 30/37] feat: optimized display --- web/src/hooks/usage-logs/useUsageLogsData.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index f2bc7988..11959d51 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -313,7 +313,7 @@ export const useLogsData = () => { if (chain.length <= 1) { return t('原生格式'); } - return chain.join(' -> '); + return `${t('转换')} ${chain.join(' -> ')}`; }; let expandDatesLocal = {}; @@ -486,6 +486,12 @@ export const useLogsData = () => { }); } } + if (other?.request_path) { + expandDataLocal.push({ + key: t('请求路径'), + value: other.request_path, + }); + } if (isAdminUser) { expandDataLocal.push({ key: t('请求转换'), From 01bcf6028e9bde7e5adb5204621d9bbcd595252e Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 00:17:20 +0800 Subject: [PATCH 31/37] feat: optimized display --- web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index 11959d51..ee34e1af 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -313,7 +313,7 @@ export const useLogsData = () => { if (chain.length <= 1) { return t('原生格式'); } - return `${t('转换')} ${chain.join(' -> ')}`; + return `${chain.join(' -> ')}`; }; let expandDatesLocal = {}; From ef84945425650637d12e195cbb9366ac13bacf04 Mon Sep 17 00:00:00 2001 From: Seefs Date: Wed, 21 Jan 2026 23:22:31 +0800 Subject: [PATCH 32/37] fix: codex rm Temperature --- relay/channel/codex/adaptor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 76b7d073..ab61dfac 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -93,6 +93,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo request.Store = json.RawMessage("false") // rm max_output_tokens request.MaxOutputTokens = 0 + request.Temperature = nil return request, nil } From e1ff2af1e0bc45b2a988f8231c4d27a9776e9804 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:38:47 +0800 Subject: [PATCH 33/37] Revert "fix: video content api Priority use url field" --- controller/video_proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 4815394a..f102baae 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -12,7 +12,6 @@ import ( "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" - "github.com/samber/lo" "github.com/gin-gonic/gin" ) @@ -135,7 +134,8 @@ func VideoProxy(c *gin.Context) { videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID) req.Header.Set("Authorization", "Bearer "+channel.Key) default: - videoURL = lo.Ternary(task.Url != "", task.Url, task.FailReason) + // Video URL is directly in task.FailReason + videoURL = task.FailReason } req.URL, err = url.Parse(videoURL) From 21c0a51dc39e9a937293a2fabd0e443ba5bf912a Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Thu, 22 Jan 2026 08:58:23 +0800 Subject: [PATCH 34/37] feat: requestId time string use UTC --- common/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/utils.go b/common/utils.go index f63df857..b67fe1c5 100644 --- a/common/utils.go +++ b/common/utils.go @@ -263,7 +263,7 @@ func GetTimestamp() int64 { } func GetTimeString() string { - now := time.Now() + now := time.Now().UTC() return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) } From 5609c7f92836f6fb0d620b14af9d26d2ff78952d Mon Sep 17 00:00:00 2001 From: Xyfacai Date: Thu, 22 Jan 2026 17:25:49 +0800 Subject: [PATCH 35/37] feat(qwen): support qwen image sync image model config --- relay/channel/ali/adaptor.go | 15 ++++++----- setting/model_setting/qwen.go | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 setting/model_setting/qwen.go diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index d9108c6a..23ef5f4b 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" @@ -23,6 +24,13 @@ type Adaptor struct { IsSyncImageModel bool } +/* + var syncModels = []string{ + "z-image", + "qwen-image", + "wan2.6", + } +*/ func supportsAliAnthropicMessages(modelName string) bool { // Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. return strings.Contains(strings.ToLower(modelName), "qwen") @@ -35,12 +43,7 @@ var syncModels = []string{ } func isSyncImageModel(modelName string) bool { - for _, m := range syncModels { - if strings.Contains(modelName, m) { - return true - } - } - return false + return model_setting.IsSyncImageModel(modelName) } func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { diff --git a/setting/model_setting/qwen.go b/setting/model_setting/qwen.go new file mode 100644 index 00000000..ccab5759 --- /dev/null +++ b/setting/model_setting/qwen.go @@ -0,0 +1,50 @@ +package model_setting + +import ( + "strings" + + "github.com/QuantumNous/new-api/setting/config" +) + +// QwenSettings defines Qwen model configuration. 注意bool要以enabled结尾才可以生效编辑 +type QwenSettings struct { + SyncImageModels []string `json:"sync_image_models"` +} + +// 默认配置 +var defaultQwenSettings = QwenSettings{ + SyncImageModels: []string{ + "z-image", + "qwen-image", + "wan2.6", + "qwen-image-edit", + "qwen-image-edit-max", + "qwen-image-edit-max-2026-01-16", + "qwen-image-edit-plus", + "qwen-image-edit-plus-2025-12-15", + "qwen-image-edit-plus-2025-10-30", + }, +} + +// 全局实例 +var qwenSettings = defaultQwenSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("qwen", &qwenSettings) +} + +// GetQwenSettings +func GetQwenSettings() *QwenSettings { + return &qwenSettings +} + +// IsSyncImageModel +func IsSyncImageModel(model string) bool { + for _, m := range qwenSettings.SyncImageModels { + if strings.Contains(model, m) { + return true + } + } + return false +} From 0dfa607147fd0929232c0deb2ede98deecc3a358 Mon Sep 17 00:00:00 2001 From: Li-Xingyu <2595205208@qq.com> Date: Sun, 25 Jan 2026 04:28:11 +0800 Subject: [PATCH 36/37] feat: enhance Authorization header handling with Header Override support --- relay/channel/api_request.go | 30 ++++++++++++++++++------------ relay/channel/openai/adaptor.go | 24 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 1ff1e239..128e9453 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -71,6 +71,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("new request failed: %w", err) } headers := req.Header + err = a.SetupRequestHeader(c, &headers, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 + // 这样可以覆盖默认的 Authorization header 设置 headerOverride, err := processHeaderOverride(info) if err != nil { return nil, err @@ -78,10 +84,6 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody for key, value := range headerOverride { headers.Set(key, value) } - err = a.SetupRequestHeader(c, &headers, info) - if err != nil { - return nil, fmt.Errorf("setup request header failed: %w", err) - } resp, err := doRequest(c, req, info) if err != nil { return nil, fmt.Errorf("do request failed: %w", err) @@ -104,6 +106,12 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header + err = a.SetupRequestHeader(c, &headers, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 + // 这样可以覆盖默认的 Authorization header 设置 headerOverride, err := processHeaderOverride(info) if err != nil { return nil, err @@ -111,10 +119,6 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod for key, value := range headerOverride { headers.Set(key, value) } - err = a.SetupRequestHeader(c, &headers, info) - if err != nil { - return nil, fmt.Errorf("setup request header failed: %w", err) - } resp, err := doRequest(c, req, info) if err != nil { return nil, fmt.Errorf("do request failed: %w", err) @@ -128,6 +132,12 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("get request url failed: %w", err) } targetHeader := http.Header{} + err = a.SetupRequestHeader(c, &targetHeader, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + // 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高 + // 这样可以覆盖默认的 Authorization header 设置 headerOverride, err := processHeaderOverride(info) if err != nil { return nil, err @@ -135,10 +145,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody for key, value := range headerOverride { targetHeader.Set(key, value) } - err = a.SetupRequestHeader(c, &targetHeader, info) - if err != nil { - return nil, fmt.Errorf("setup request header failed: %w", err) - } targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type")) targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader) if err != nil { diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f40f5da6..f9a2e8ea 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -184,9 +184,25 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info * header.Set("api-key", info.ApiKey) return nil } + // 自定义渠道类型完全跳过默认 Authorization 设置,由 Header Override 控制 + if info.ChannelType == constant.ChannelTypeCustom { + // 自定义渠道不设置默认 Authorization,完全由 Header Override 控制 + return nil + } if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization { header.Set("OpenAI-Organization", info.Organization) } + // 检查 Header Override 是否已设置 Authorization,如果已设置则跳过默认设置 + // 这样可以避免在 Header Override 应用时被覆盖(虽然 Header Override 会在之后应用,但这里作为额外保护) + hasAuthOverride := false + if len(info.HeadersOverride) > 0 { + for k := range info.HeadersOverride { + if strings.EqualFold(k, "Authorization") { + hasAuthOverride = true + break + } + } + } if info.RelayMode == relayconstant.RelayModeRealtime { swp := c.Request.Header.Get("Sec-WebSocket-Protocol") if swp != "" { @@ -201,10 +217,14 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info * //req.Header.Set("Sec-Websocket-Version", c.Request.Header.Get("Sec-Websocket-Version")) } else { header.Set("openai-beta", "realtime=v1") - header.Set("Authorization", "Bearer "+info.ApiKey) + if !hasAuthOverride { + header.Set("Authorization", "Bearer "+info.ApiKey) + } } } else { - header.Set("Authorization", "Bearer "+info.ApiKey) + if !hasAuthOverride { + header.Set("Authorization", "Bearer "+info.ApiKey) + } } if info.ChannelType == constant.ChannelTypeOpenRouter { header.Set("HTTP-Referer", "https://www.newapi.ai") From 36b0f4b0ae08676c10caba7dfed0d66fa08362c0 Mon Sep 17 00:00:00 2001 From: Li-Xingyu <2595205208@qq.com> Date: Sun, 25 Jan 2026 14:36:37 +0800 Subject: [PATCH 37/37] feat: enhance Authorization header handling with Header Override support --- relay/channel/openai/adaptor.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f9a2e8ea..c031fd75 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -184,11 +184,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info * header.Set("api-key", info.ApiKey) return nil } - // 自定义渠道类型完全跳过默认 Authorization 设置,由 Header Override 控制 - if info.ChannelType == constant.ChannelTypeCustom { - // 自定义渠道不设置默认 Authorization,完全由 Header Override 控制 - return nil - } if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization { header.Set("OpenAI-Organization", info.Organization) }