Kaynağa Gözat

feat: /v1/chat/completion -> /v1/response (#2629)

* feat: /v1/chat/completion -> /v1/response
Seefs 1 ay önce
ebeveyn
işleme
62b796fa6a

+ 2 - 2
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"`

+ 10 - 7
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 {

+ 234 - 0
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
+}

+ 160 - 0
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
+}

+ 23 - 0
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 {

+ 18 - 0
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)
+}

+ 14 - 0
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)
+}

+ 262 - 0
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
+}

+ 18 - 0
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,
+	)
+}

+ 33 - 0
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
+}

+ 133 - 0
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()
+}

+ 28 - 2
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,
+	},
 }
 
 // 全局实例

+ 9 - 2
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.

+ 5 - 0
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?",

+ 5 - 0
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 »."

+ 5 - 0
web/src/i18n/locales/ja.json

@@ -2580,6 +2580,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "注意: ここでの設定は「モデル広場」での表示にのみ影響し、実際の呼び出しやルーティングには影響しません。実際の呼び出しを設定する場合は、「チャネル管理」で設定してください。",
     "确认关闭提示": "閉じる確認",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "閉じると、このお知らせは今後表示されません(このブラウザのみ)。閉じてもよろしいですか?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 互換設定(ベータ)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "注意: この機能はベータ版です。今後、設定構造や挙動が変更される可能性があります。本番環境では使用しないでください。",
+    "填充模板(指定渠道)": "テンプレートを入力(指定チャネル)",
+    "填充模板(全渠道)": "テンプレートを入力(全チャネル)",
+    "格式化 JSON": "JSON を整形",
     "关闭提示": "お知らせを閉じる",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "注意: このページのテストは非ストリーミングリクエストです。チャネルがストリーミング応答のみ対応の場合、テストが失敗することがあります。実際の利用結果を優先してください。",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。"

+ 5 - 0
web/src/i18n/locales/ru.json

@@ -2610,6 +2610,11 @@
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "Примечание: эта настройка влияет только на отображение моделей в «Маркетплейсе моделей» и не влияет на фактический вызов или маршрутизацию. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
     "确认关闭提示": "Подтвердить закрытие",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "После закрытия это уведомление больше не будет показываться (только в этом браузере). Закрыть?",
+    "ChatCompletions→Responses 兼容配置(Beta)": "Совместимость ChatCompletions→Responses (бета)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "Примечание: это бета-функция. Структура конфигурации и поведение могут измениться в будущем. Не используйте в продакшене.",
+    "填充模板(指定渠道)": "Заполнить шаблон (выбранные каналы)",
+    "填充模板(全渠道)": "Заполнить шаблон (все каналы)",
+    "格式化 JSON": "Форматировать JSON",
     "关闭提示": "Закрыть уведомление",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.",
     "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами»."

+ 5 - 0
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\"."

+ 5 - 0
web/src/i18n/locales/zh.json

@@ -2569,6 +2569,11 @@
     "签到最大额度": "签到最大额度",
     "签到奖励的最大额度": "签到奖励的最大额度",
     "保存签到设置": "保存签到设置",
+    "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)",
+    "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。",
+    "填充模板(指定渠道)": "填充模板(指定渠道)",
+    "填充模板(全渠道)": "填充模板(全渠道)",
+    "格式化 JSON": "格式化 JSON",
     "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。": "提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。",
     "确认关闭提示": "确认关闭提示",
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",

+ 160 - 1
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) {
               </Col>
             </Row>
 
+            <Form.Section text={t('ChatCompletions→Responses 兼容配置')}>
+              <Row style={{ marginTop: 10 }}>
+                <Col span={24}>
+                  <Banner
+                    type='warning'
+                    title={
+                      <span>
+                        {t('ChatCompletions→Responses 兼容配置')}{' '}
+                        <Tag color='red' size='small'>
+                          Alpha
+                        </Tag>
+                      </span>
+                    }
+                    description={t(
+                      '提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。',
+                    )}
+                  />
+                </Col>
+              </Row>
+
+              <Row style={{ marginTop: 10 }}>
+                <Col span={24}>
+                  <div
+                    style={{
+                      display: 'flex',
+                      gap: 8,
+                      flexWrap: 'wrap',
+                      alignItems: 'center',
+                    }}
+                  >
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() =>
+                        setInputs({
+                          ...inputs,
+                          'global.chat_completions_to_responses_policy':
+                            chatCompletionsToResponsesPolicyExample,
+                        })
+                      }
+                    >
+                      {t('填充模板(指定渠道)')}
+                    </Button>
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() =>
+                        setInputs({
+                          ...inputs,
+                          'global.chat_completions_to_responses_policy':
+                            chatCompletionsToResponsesPolicyAllChannelsExample,
+                        })
+                      }
+                    >
+                      {t('填充模板(全渠道)')}
+                    </Button>
+                    <Button
+                      type='secondary'
+                      size='small'
+                      onClick={() => {
+                        const raw =
+                          inputs['global.chat_completions_to_responses_policy'];
+                        if (!raw || String(raw).trim() === '') return;
+                        try {
+                          const formatted = JSON.stringify(
+                            JSON.parse(raw),
+                            null,
+                            2,
+                          );
+                          setInputs({
+                            ...inputs,
+                            'global.chat_completions_to_responses_policy':
+                              formatted,
+                          });
+                        } catch (error) {
+                          showError(t('不是合法的 JSON 字符串'));
+                        }
+                      }}
+                    >
+                      {t('格式化 JSON')}
+                    </Button>
+                  </div>
+                </Col>
+              </Row>
+
+              <Row style={{ marginTop: 10 }}>
+                <Col span={24}>
+                  <Form.TextArea
+                    label={t('配置 JSON')}
+                    field={'global.chat_completions_to_responses_policy'}
+                    placeholder={
+                      t('例如(指定渠道):') +
+                      '\n' +
+                      chatCompletionsToResponsesPolicyExample +
+                      '\n\n' +
+                      t('例如(全渠道):') +
+                      '\n' +
+                      chatCompletionsToResponsesPolicyAllChannelsExample
+                    }
+                    rows={8}
+                    rules={[
+                      {
+                        validator: (rule, value) => {
+                          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,
+                      })
+                    }
+                  />
+                </Col>
+              </Row>
+            </Form.Section>
+
             <Form.Section text={t('连接保活设置')}>
               <Row style={{ marginTop: 10 }}>
                 <Col span={24}>