diff --git a/Dockerfile b/Dockerfile index d01ab3f0f038..cbb56268cdeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,13 +25,17 @@ ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS ARG TARGETARCH +ARG GOPROXY=https://goproxy.cn,direct ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} ENV GOEXPERIMENT=greenteagc +ENV GOPROXY=${GOPROXY} WORKDIR /build ADD go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/cache/go-build \ + GOCACHE=/cache/go-build go mod download COPY . . COPY --from=builder /build/web/default/dist ./web/default/dist diff --git a/common/constants.go b/common/constants.go index c7d5637c8e9a..f90d61becd7e 100644 --- a/common/constants.go +++ b/common/constants.go @@ -22,7 +22,7 @@ var TopUpLink = "" var themeValue atomic.Value // stores string; safe for concurrent read/write func init() { - themeValue.Store("classic") + themeValue.Store("default") } func GetTheme() string { diff --git a/docker-compose.yml b/docker-compose.yml index be8c885b186a..d6a16e0d6662 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,8 @@ version: '3.4' # For compatibility with older Docker versions services: new-api: - image: calciumion/new-api:latest + #image: calciumion/new-api:latest + build: . container_name: new-api restart: always command: --log-dir /app/logs diff --git a/dto/openai_request.go b/dto/openai_request.go index 8c104ddd242d..a2787cad815c 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -237,6 +237,7 @@ type FunctionRequest struct { Description string `json:"description,omitempty"` Name string `json:"name"` Parameters any `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` Arguments string `json:"arguments,omitempty"` } diff --git a/relay/channel/openai/chat_to_responses_stream.go b/relay/channel/openai/chat_to_responses_stream.go new file mode 100644 index 000000000000..eaf42a5fb44b --- /dev/null +++ b/relay/channel/openai/chat_to_responses_stream.go @@ -0,0 +1,576 @@ +package openai + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "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" +) + +// OaiChatToResponsesHandler 将非流式的 Chat Completions 上游响应转换为 Responses API 格式写回客户端。 +// 用于 stream=false 时,读取完整响应体后通过 service.ChatCompletionsResponseToResponsesResponse 转换。 +func OaiChatToResponsesHandler(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 chatResp dto.OpenAITextResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + if err := common.Unmarshal(body, &chatResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if oaiError := chatResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + responsesResp, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + responseBody, err := common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + usage := &dto.Usage{} + if chatResp.Usage.PromptTokens > 0 || chatResp.Usage.CompletionTokens > 0 { + usage.PromptTokens = chatResp.Usage.PromptTokens + usage.InputTokens = chatResp.Usage.PromptTokens + usage.CompletionTokens = chatResp.Usage.CompletionTokens + usage.OutputTokens = chatResp.Usage.CompletionTokens + usage.TotalTokens = chatResp.Usage.TotalTokens + usage.PromptTokensDetails = chatResp.Usage.PromptTokensDetails + usage.CompletionTokenDetails = chatResp.Usage.CompletionTokenDetails + } + + return usage, nil +} + +// toolCallState 追踪流式构建中的单个 function_call 输出项。 +// Chat Completions 流式协议中,工具调用信息可能分多个 chunk 到达: +// 第一个 chunk 包含 id + name + 空 arguments,后续 chunk 携带 arguments 增量。 +// 通过 Chat Completions 的 index 字段(而非 callID)来跨 chunk 关联同一个工具调用。 +type toolCallState struct { + callID string + name string + args string + itemIdx int + nameDone bool + addedEmitted bool +} + +// OaiChatToResponsesStreamHandler 将流式 Chat Completions 上游响应(SSE 格式)转换为 Responses API SSE 事件写回客户端。 +// 用于 stream=true 时,实时转换每个 chunk。实现参考 codex-proxy 的 StreamTranslator 类: +// +// 事件序列(以含 reasoning + text + tool_calls 的响应为例): +// +// response.created +// → output_item.added (reasoning) → summary_part.added → summary_text.delta × N → summary_part.done → output_item.done +// → output_item.added (message) → content_part.added → output_text.delta × N → content_part.done → output_item.done +// → output_item.added (function_call) → function_call_arguments.delta × N +// → function_call_arguments.done → output_item.done +// → response.completed (含完整 output 数组和 usage) +// +// 追踪三种并发输出类型(reasoning、text、function_call),各自维护独立的状态。 +// 当 content 或 tool_calls 到达时,自动关闭 reasoning 输出项。 +func OaiChatToResponsesStreamHandler(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) + + respID := helper.GetResponseID(c) + if !strings.HasPrefix(respID, "resp_") { + respID = "resp_" + respID + } + model := info.UpstreamModelName + + var ( + usage = &dto.Usage{} + usageText strings.Builder + streamErr *types.NewAPIError + createdSent bool + isFinished bool + + // All output items accumulated during the stream + outputItems []dto.ResponsesOutput + + // Reasoning state + reasStarted bool + reasIdx int + reasID string + reasContentIdx int + reasBuf strings.Builder + + // Text/message state + textStarted bool + textIdx int + textContentIdx int + accumulatedText strings.Builder + + // Tool call state keyed by the Chat Completions index field + tcBuf = make(map[int]*toolCallState) + ) + + // sendResponsesEvent sends a Responses SSE event to the client. + sendResponsesEvent := func(eventType string, data any) bool { + payload := map[string]any{"type": eventType} + switch v := data.(type) { + case map[string]any: + for k, val := range v { + payload[k] = val + } + default: + payload["data"] = data + } + jsonData, err := common.Marshal(payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + logger.LogDebug(c, "responses sse event: %s %s", eventType, string(jsonData)) + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventType}, string(jsonData)) + return true + } + + // sendCreatedIfNeeded sends the response.created event once. + sendCreatedIfNeeded := func() bool { + if createdSent { + return true + } + event := map[string]any{ + "response": map[string]any{ + "id": respID, + "object": "response", + "model": model, + "status": "in_progress", + "output": []any{}, + }, + } + if !sendResponsesEvent("response.created", event) { + return false + } + createdSent = true + return true + } + + // ── Reasoning handling ── + + startReasoning := func() { + if reasStarted { + return + } + reasStarted = true + outputIdx := len(outputItems) + reasIdx = outputIdx + reasContentIdx = 0 + reasBuf.Reset() + reasID = respID + "_reas_0" + + item := dto.ResponsesOutput{ + Type: "reasoning", + ID: reasID, + Status: "in_progress", + Content: []dto.ResponsesOutputContent{ + {Type: "summary_text", Text: ""}, + }, + } + outputItems = append(outputItems, item) + + if !sendCreatedIfNeeded() { + return + } + sendResponsesEvent("response.output_item.added", map[string]any{ + "output_index": outputIdx, + "item": outputItems[outputIdx], + }) + sendResponsesEvent("response.reasoning_summary_part.added", map[string]any{ + "output_index": outputIdx, + "content_index": reasContentIdx, + "part": outputItems[outputIdx].Content[0], + }) + } + + handleReasoning := func(delta string) { + if streamErr != nil { + return + } + if !reasStarted { + startReasoning() + } + if streamErr != nil { + return + } + reasBuf.WriteString(delta) + outputItems[reasIdx].Content[0].Text = reasBuf.String() + + sendResponsesEvent("response.reasoning_summary_text.delta", map[string]any{ + "output_index": reasIdx, + "content_index": reasContentIdx, + "delta": delta, + }) + } + + finalizeReasoning := func() { + if !reasStarted { + return + } + item := &outputItems[reasIdx] + item.Status = "completed" + item.Content[0].Text = reasBuf.String() + + sendResponsesEvent("response.reasoning_summary_part.done", map[string]any{ + "output_index": reasIdx, + "content_index": reasContentIdx, + "part": item.Content[0], + }) + sendResponsesEvent("response.output_item.done", map[string]any{ + "output_index": reasIdx, + "item": *item, + }) + reasStarted = false + } + + // ── Text/message handling ── + + startText := func() { + if textStarted { + return + } + textStarted = true + outputIdx := len(outputItems) + textIdx = outputIdx + textContentIdx = 0 + accumulatedText.Reset() + + item := dto.ResponsesOutput{ + Type: "message", + ID: respID + "_msg_0", + Status: "in_progress", + Role: "assistant", + Content: []dto.ResponsesOutputContent{ + {Type: "output_text", Text: "", Annotations: []interface{}{}}, + }, + } + outputItems = append(outputItems, item) + + if !sendCreatedIfNeeded() { + return + } + sendResponsesEvent("response.output_item.added", map[string]any{ + "output_index": outputIdx, + "item": outputItems[outputIdx], + }) + sendResponsesEvent("response.content_part.added", map[string]any{ + "output_index": outputIdx, + "content_index": textContentIdx, + "part": outputItems[outputIdx].Content[0], + }) + } + + handleText := func(delta string) { + if streamErr != nil { + return + } + if !textStarted { + startText() + } + if streamErr != nil { + return + } + accumulatedText.WriteString(delta) + usageText.WriteString(delta) + outputItems[textIdx].Content[0].Text = accumulatedText.String() + + sendResponsesEvent("response.output_text.delta", map[string]any{ + "output_index": textIdx, + "content_index": textContentIdx, + "delta": delta, + }) + } + + finalizeText := func() { + if !textStarted { + return + } + item := &outputItems[textIdx] + item.Status = "completed" + item.Content[0].Text = accumulatedText.String() + + sendResponsesEvent("response.content_part.done", map[string]any{ + "output_index": textIdx, + "content_index": textContentIdx, + "part": item.Content[0], + }) + sendResponsesEvent("response.output_item.done", map[string]any{ + "output_index": textIdx, + "item": *item, + }) + textStarted = false + } + + // ── Tool call handling ── + + handleToolCall := func(tc dto.ToolCallResponse) { + tcIndex := 0 + if tc.Index != nil { + tcIndex = *tc.Index + } + + buf, exists := tcBuf[tcIndex] + if !exists { + callID := tc.ID + if callID == "" { + callID = fmt.Sprintf("%s_tc_%d", respID, len(tcBuf)) + } + fn := tc.Function + itemIdx := len(outputItems) + name := fn.Name + + logger.LogDebug(c, "responses stream: new tool_call idx=%d name=%s callID=%s", tcIndex, name, callID) + buf = &toolCallState{ + callID: callID, + name: name, + args: "", + itemIdx: itemIdx, + nameDone: name != "", + } + tcBuf[tcIndex] = buf + + item := dto.ResponsesOutput{ + Type: "function_call", + ID: callID, + Status: "in_progress", + CallId: callID, + Name: name, + } + outputItems = append(outputItems, item) + + // Only emit output_item.added if we have the name now + if name != "" { + if !sendCreatedIfNeeded() { + return + } + sendResponsesEvent("response.output_item.added", map[string]any{ + "output_index": itemIdx, + "item": outputItems[itemIdx], + }) + buf.addedEmitted = true + } + } + + fn := tc.Function + + // Name arrives in a later chunk + if fn.Name != "" && !buf.nameDone { + buf.name = fn.Name + buf.nameDone = true + outputItems[buf.itemIdx].Name = fn.Name + + if !buf.addedEmitted { + buf.addedEmitted = true + if !sendCreatedIfNeeded() { + return + } + sendResponsesEvent("response.output_item.added", map[string]any{ + "output_index": buf.itemIdx, + "item": outputItems[buf.itemIdx], + }) + } + } + + if !buf.addedEmitted && buf.nameDone { + buf.addedEmitted = true + } + + // Arguments delta + if fn.Arguments != "" { + buf.args += fn.Arguments + // arguments must be a JSON string in the Responses API, not a raw JSON object + argsJSON, _ := json.Marshal(buf.args) + outputItems[buf.itemIdx].Arguments = argsJSON + usageText.WriteString(fn.Arguments) + + sendResponsesEvent("response.function_call_arguments.delta", map[string]any{ + "output_index": buf.itemIdx, + "call_id": buf.callID, + "delta": fn.Arguments, + }) + } + } + + finalizeAllToolCalls := func() { + for _, buf := range tcBuf { + item := &outputItems[buf.itemIdx] + item.Status = "completed" + + sendResponsesEvent("response.function_call_arguments.done", map[string]any{ + "output_index": buf.itemIdx, + "call_id": buf.callID, + "arguments": buf.args, + }) + sendResponsesEvent("response.output_item.done", map[string]any{ + "output_index": buf.itemIdx, + "item": *item, + }) + } + tcBuf = make(map[int]*toolCallState) + } + + // ── Finish ── + + finish := func() { + if isFinished { + return + } + logger.LogDebug(c, "responses stream finish called, reasStarted=%v textStarted=%v toolCalls=%d", reasStarted, textStarted, len(tcBuf)) + isFinished = true + + // Finalize active output items: reasoning → text → tool_calls + if reasStarted { + finalizeReasoning() + } + if textStarted { + finalizeText() + } + finalizeAllToolCalls() + + if !sendCreatedIfNeeded() { + return + } + + // Estimate usage if upstream did not provide it + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + + sendResponsesEvent("response.completed", map[string]any{ + "response": map[string]any{ + "id": respID, + "object": "response", + "model": model, + "status": "completed", + "output": outputItems, + "usage": map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + }, + }, + }) + } + + // ── Stream processing ── + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + if streamErr != nil { + sr.Stop(streamErr) + return + } + + if len(data) == 0 { + return + } + + var streamResp dto.ChatCompletionsStreamResponse + if err := common.Unmarshal([]byte(data), &streamResp); err != nil { + logger.LogError(c, "failed to unmarshal chat stream chunk: "+err.Error()) + sr.Error(err) + return + } + + if streamResp.Id != "" { + respID = streamResp.Id + if !strings.HasPrefix(respID, "resp_") { + respID = "resp_" + respID + } + } + if streamResp.Model != "" { + model = streamResp.Model + } + + if len(streamResp.Choices) == 0 { + if streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) { + usage = streamResp.Usage + } + return + } + + choice := streamResp.Choices[0] + delta := choice.Delta + + // Reasoning content delta + if delta.ReasoningContent != nil && *delta.ReasoningContent != "" { + handleReasoning(*delta.ReasoningContent) + } + + // Close reasoning when content or tool_calls arrives + if delta.Content != nil && *delta.Content != "" && reasStarted { + finalizeReasoning() + } + if len(delta.ToolCalls) > 0 && reasStarted { + finalizeReasoning() + } + + // Content delta + if delta.Content != nil && *delta.Content != "" { + handleText(*delta.Content) + } + + // Tool calls delta - use index field for tracking + if len(delta.ToolCalls) > 0 { + for _, tc := range delta.ToolCalls { + handleToolCall(tc) + } + } + + // Finish reason + if choice.FinishReason != nil && *choice.FinishReason != "" { + finish() + } + + // Extract usage from stream chunks + if streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) { + usage = streamResp.Usage + } + }) + + // If the stream ended without finish_reason, force finish + if !isFinished { + logger.LogWarn(c, "stream ended without finish_reason, forcing finish") + finish() + } + + if streamErr != nil { + return nil, streamErr + } + + + + + return usage, nil +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 010c38bba865..0705e3fe19ba 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -70,6 +70,22 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) } adaptor.Init(info) + + passThroughGlobal := model_setting.GetGlobalSettings().PassThroughRequestEnabled + if !passThroughGlobal && !info.ChannelSetting.PassThroughBodyEnabled && + service.ShouldResponsesUseChatCompletionsGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) { + usage, newApiErr := responsesViaChatCompletions(c, info, request) + if newApiErr != nil { + return newApiErr + } + if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, info, usage, "") + } else { + service.PostTextConsumeQuota(c, info, usage, nil) + } + return nil + } + var requestBody io.Reader if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { storage, err := common.GetBodyStorage(c) diff --git a/relay/responses_via_chat_completions.go b/relay/responses_via_chat_completions.go new file mode 100644 index 000000000000..79bec15e6355 --- /dev/null +++ b/relay/responses_via_chat_completions.go @@ -0,0 +1,128 @@ +package relay + +import ( + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + 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" +) + +// responsesViaChatCompletions 是 Responses→ChatCompletions 协议降级的编排函数。 +// 与 chatCompletionsViaResponses 对称,流程: +// 1. 将 Responses 请求体转为 Chat Completions 格式 +// 2. 临时切换 RelayMode 和 RequestURLPath +// 3. 调用 adaptor.ConvertOpenAIRequest 获取渠道特定请求 +// 4. 发送请求给上游 /v1/chat/completions +// 5. 将上游响应(流式或非流式)转回 Responses 格式 +// 6. 恢复原始 RelayMode +func responsesViaChatCompletions(c *gin.Context, info *relaycommon.RelayInfo, responsesReq *dto.OpenAIResponsesRequest) (*dto.Usage, *types.NewAPIError) { + chatReq, err := service.ResponsesRequestToChatCompletionsRequest(responsesReq) + if err != nil { + return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + // Serialize and apply field removal + param override + chatJSON, err := common.Marshal(chatReq) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if len(info.ParamOverride) > 0 { + chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info) + if err != nil { + return nil, newAPIErrorFromParamOverride(err) + } + } + + var overriddenChatReq dto.GeneralOpenAIRequest + if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil { + return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + + // Temporarily switch to ChatCompletions mode for upstream request + savedRelayMode := info.RelayMode + savedRequestURLPath := info.RequestURLPath + defer func() { + info.RelayMode = savedRelayMode + info.RequestURLPath = savedRequestURLPath + }() + + info.RelayMode = relayconstant.RelayModeChatCompletions + info.RequestURLPath = "/v1/chat/completions" + + // Use the adaptor's ChatCompletions conversion path + adaptor := GetAdaptor(info.ApiType) + convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, &overriddenChatReq) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) + + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + defer closer.Close() + jsonData = nil + info.UpstreamRequestBodySize = size + var requestBody io.Reader = body + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, requestBody) + 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.OaiChatToResponsesStreamHandler(c, info, httpResp) + if newApiErr != nil { + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + return usage, nil + } + + usage, newApiErr := openaichannel.OaiChatToResponsesHandler(c, info, httpResp) + if newApiErr != nil { + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return nil, newApiErr + } + return usage, nil +} \ No newline at end of file diff --git a/service/openai_chat_responses_compat.go b/service/openai_chat_responses_compat.go index 2e887386339d..ce37d21e68bd 100644 --- a/service/openai_chat_responses_compat.go +++ b/service/openai_chat_responses_compat.go @@ -9,6 +9,14 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d return openaicompat.ChatCompletionsRequestToResponsesRequest(req) } +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + return openaicompat.ResponsesRequestToChatCompletionsRequest(req) +} + +func ChatCompletionsResponseToResponsesResponse(chatResp *dto.OpenAITextResponse) (*dto.OpenAIResponsesResponse, error) { + return openaicompat.ChatCompletionsResponseToResponsesResponse(chatResp) +} + func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id) } diff --git a/service/openai_chat_responses_mode.go b/service/openai_chat_responses_mode.go index c66c33c9dc91..7910ee6e87c3 100644 --- a/service/openai_chat_responses_mode.go +++ b/service/openai_chat_responses_mode.go @@ -12,3 +12,11 @@ func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletion func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool { return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model) } + +func ShouldResponsesUseChatCompletionsPolicy(policy model_setting.ResponsesToChatCompletionsPolicy, channelID int, channelType int, model string) bool { + return openaicompat.ShouldResponsesUseChatCompletionsPolicy(policy, channelID, channelType, model) +} + +func ShouldResponsesUseChatCompletionsGlobal(channelID int, channelType int, model string) bool { + return openaicompat.ShouldResponsesUseChatCompletionsGlobal(channelID, channelType, model) +} diff --git a/service/openaicompat/chat_to_responses_response.go b/service/openaicompat/chat_to_responses_response.go new file mode 100644 index 000000000000..77004d02be32 --- /dev/null +++ b/service/openaicompat/chat_to_responses_response.go @@ -0,0 +1,157 @@ +package openaicompat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +// ChatCompletionsResponseToResponsesResponse 将非流式的 Chat Completions 响应转换为 Responses API 格式。 +// 通常用于非流式请求(stream=false)的响应转换。 +// 映射: +// - reasoning_content → reasoning 类型 output item(summary_text) +// - message.content → message 类型 output item(output_text) +// - message.tool_calls → function_call 类型 output items +// - usage → input_tokens / output_tokens +func ChatCompletionsResponseToResponsesResponse(chatResp *dto.OpenAITextResponse) (*dto.OpenAIResponsesResponse, error) { + if chatResp == nil { + return nil, fmt.Errorf("response is nil") + } + + // Generate a Responses-format ID + id := chatResp.Id + if !strings.HasPrefix(id, "resp_") { + id = "resp_" + id + } + + // Extract created timestamp + createdAt := 0 + switch v := chatResp.Created.(type) { + case int: + createdAt = v + case int64: + createdAt = int(v) + case float64: + createdAt = int(v) + case json.Number: + if i, err := v.Int64(); err == nil { + createdAt = int(i) + } + } + + output := make([]dto.ResponsesOutput, 0) + + if len(chatResp.Choices) > 0 { + choice := chatResp.Choices[0] + msg := choice.Message + + // reasoning_content → reasoning output item + reasoningText := "" + if msg.ReasoningContent != nil { + reasoningText = *msg.ReasoningContent + } else if msg.Reasoning != nil { + reasoningText = *msg.Reasoning + } + if reasoningText != "" { + output = append(output, dto.ResponsesOutput{ + Type: "reasoning", + ID: id + "_reas_0", + Status: "completed", + Content: []dto.ResponsesOutputContent{ + {Type: "summary_text", Text: reasoningText}, + }, + }) + } + + // Determine if there are tool calls + var toolCalls []dto.ToolCallResponse + if msg.ToolCalls != nil { + _ = json.Unmarshal(msg.ToolCalls, &toolCalls) + } + + // Extract text content + text := "" + if msg.Content != nil { + switch v := msg.Content.(type) { + case string: + text = v + } + } + + // Create message output item (only if there's text content or no tool calls) + if text != "" || len(toolCalls) == 0 { + contentItems := make([]dto.ResponsesOutputContent, 0) + if text != "" || (len(toolCalls) == 0 && reasoningText == "") { + contentItems = append(contentItems, dto.ResponsesOutputContent{ + Type: "output_text", + Text: text, + Annotations: []interface{}{}, + }) + } + + output = append(output, dto.ResponsesOutput{ + Type: "message", + ID: id + "_msg_0", + Status: "completed", + Role: "assistant", + Content: contentItems, + }) + } + + // Create function_call output items for each tool call + for i, tc := range toolCalls { + callID := tc.ID + if callID == "" { + callID = fmt.Sprintf("%s_fc_%d", id, i) + } + + // Ensure arguments is valid JSON + args := tc.Function.Arguments + if args == "" { + args = "{}" + } + + // arguments must be a JSON string in the Responses API + argsJSON, _ := json.Marshal(args) + output = append(output, dto.ResponsesOutput{ + Type: "function_call", + ID: fmt.Sprintf("%s_fc_%d", id, i), + Status: "completed", + CallId: callID, + Name: tc.Function.Name, + Arguments: argsJSON, + }) + } + } + + // Build usage + usage := &dto.Usage{} + if chatResp.Usage.PromptTokens > 0 || chatResp.Usage.CompletionTokens > 0 { + usage.PromptTokens = chatResp.Usage.PromptTokens + usage.InputTokens = chatResp.Usage.PromptTokens + usage.CompletionTokens = chatResp.Usage.CompletionTokens + usage.OutputTokens = chatResp.Usage.CompletionTokens + usage.TotalTokens = chatResp.Usage.TotalTokens + usage.PromptTokensDetails = chatResp.Usage.PromptTokensDetails + usage.CompletionTokenDetails = chatResp.Usage.CompletionTokenDetails + } + + // Build status based on finish_reason + statusStr := "completed" + statusJSON, _ := common.Marshal(statusStr) + + out := &dto.OpenAIResponsesResponse{ + ID: id, + Object: "response", + CreatedAt: createdAt, + Status: statusJSON, + Model: chatResp.Model, + Output: output, + Usage: usage, + } + + return out, nil +} diff --git a/service/openaicompat/policy.go b/service/openaicompat/policy.go index b600b0fdc799..756a0a9ddeb4 100644 --- a/service/openaicompat/policy.go +++ b/service/openaicompat/policy.go @@ -17,3 +17,22 @@ func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, mod model, ) } + +func ShouldResponsesUseChatCompletionsPolicy(policy model_setting.ResponsesToChatCompletionsPolicy, channelID int, channelType int, model string) bool { + if !policy.IsChannelEnabled(channelID, channelType) { + return false + } + if len(policy.ModelPatterns) == 0 { + return true + } + return matchAnyRegex(policy.ModelPatterns, model) +} + +func ShouldResponsesUseChatCompletionsGlobal(channelID int, channelType int, model string) bool { + return ShouldResponsesUseChatCompletionsPolicy( + model_setting.GetGlobalSettings().ResponsesToChatCompletionsPolicy, + channelID, + channelType, + model, + ) +} diff --git a/service/openaicompat/responses_to_chat_request.go b/service/openaicompat/responses_to_chat_request.go new file mode 100644 index 000000000000..8ec1ed2a7be8 --- /dev/null +++ b/service/openaicompat/responses_to_chat_request.go @@ -0,0 +1,613 @@ +package openaicompat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/samber/lo" +) + +// ResponsesRequestToChatCompletionsRequest 将 Responses API 请求体转换为 Chat Completions 请求体。 +// 这是反向转换的关键入口,映射包括: +// - instructions → system 消息 +// - input 数组 → messages 数组(含 role 映射、tool_call/tool_result 配对) +// - tools → Chat Completions tools 格式(含 strict 透传) +// - max_output_tokens → max_completion_tokens +// - reasoning.effort → reasoning_effort +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + if req == nil { + return nil, errors.New("request is nil") + } + if req.Model == "" { + return nil, errors.New("model is required") + } + + messages := make([]dto.Message, 0) + + // instructions → system message + if req.Instructions != nil { + var instructions string + if common.GetJsonType(req.Instructions) == "string" { + _ = common.Unmarshal(req.Instructions, &instructions) + } + if strings.TrimSpace(instructions) != "" { + messages = append(messages, dto.Message{ + Role: "system", + Content: instructions, + }) + } + } + + // input → messages + inputMessages, err := convertResponsesInputToMessages(req.Input) + if err != nil { + return nil, fmt.Errorf("failed to convert input: %w", err) + } + messages = append(messages, inputMessages...) + + // tools → ChatCompletions tools format + var tools []dto.ToolCallRequest + if req.Tools != nil { + chatTools, err := convertResponsesToolsToChatTools(req.Tools) + if err != nil { + return nil, fmt.Errorf("failed to convert tools: %w", err) + } + tools = chatTools + } + + // tool_choice → ChatCompletions tool_choice (only if tools are present) + var toolChoice any + if req.ToolChoice != nil && len(tools) > 0 { + toolChoice = convertResponsesToolChoiceToChatToolChoice(req.ToolChoice) + } + + // text → response_format + var responseFormat *dto.ResponseFormat + if req.Text != nil { + responseFormat = convertResponsesTextToResponseFormat(req.Text) + } + + // max_output_tokens → max_completion_tokens + var maxCompletionTokens *uint + if req.MaxOutputTokens != nil { + maxCompletionTokens = req.MaxOutputTokens + } + + // reasoning → reasoning_effort + reasoningEffort := "" + if req.Reasoning != nil && req.Reasoning.Effort != "" { + reasoningEffort = req.Reasoning.Effort + } + + // parallel_tool_calls → *bool + var parallelToolCalls *bool + if req.ParallelToolCalls != nil { + var ptc bool + if err := common.Unmarshal(req.ParallelToolCalls, &ptc); err == nil { + parallelToolCalls = &ptc + } + } + + // user + var user json.RawMessage + if req.User != nil { + user = req.User + } + + out := &dto.GeneralOpenAIRequest{ + Model: req.Model, + Messages: messages, + Stream: req.Stream, + Temperature: req.Temperature, + TopP: req.TopP, + MaxCompletionTokens: maxCompletionTokens, + ReasoningEffort: reasoningEffort, + Tools: tools, + ToolChoice: toolChoice, + ResponseFormat: responseFormat, + User: user, + ParallelTooCalls: parallelToolCalls, + } + + return out, nil +} + +// pendingCall 记录尚未配对的 function_call 项,等待对应的 function_call_output 到达后一起 flush。 +// Responses API 中 function_call 和 function_call_output 是独立的 input 项, +// 通过 call_id 关联,需要配对后合并为 Chat Completions 的 assistant tool_calls + tool 消息。 +type pendingCall struct { + ID string + Name string + Args string +} + +// convertResponsesInputToMessages 将 Responses API 的 input 数组转换为 Chat Completions 的 messages 数组。 +// 处理逻辑参考 codex-proxy 的 _mapInputToMessages: +// - reasoning 项 → 缓存为 pendingReasoning,附加到下一个 assistant 消息的 reasoning_content +// - function_call 项 → 加入 pendingCalls 队列,等待配对 +// - function_call_output 项 → 标记对应 ID 已响应,触发 flushPendingCalls 刷出 assistant 消息 +// - 其他 role 项 → 直接转换为 Message,developer → system 角色映射 +func convertResponsesInputToMessages(input json.RawMessage) ([]dto.Message, error) { + if input == nil { + return nil, nil + } + + jsonType := common.GetJsonType(input) + + // Simple string input → single user message + if jsonType == "string" { + var str string + _ = common.Unmarshal(input, &str) + return []dto.Message{ + {Role: "user", Content: str}, + }, nil + } + + // Array of items + if jsonType != "array" { + return nil, nil + } + + var items []map[string]any + if err := common.Unmarshal(input, &items); err != nil { + return nil, fmt.Errorf("failed to unmarshal input array: %w", err) + } + + messages := make([]dto.Message, 0) + + // Track pending function calls and their responses + var pendingCalls []pendingCall + respondedIDs := make(map[string]bool) + pendingReasoning := "" + + // flushPendingCalls emits pending function_calls whose IDs have been responded to, + // then appends the corresponding tool messages. + flushPendingCalls := func() { + if len(pendingCalls) == 0 { + return + } + var resolved []pendingCall + var remaining []pendingCall + for _, pc := range pendingCalls { + if respondedIDs[pc.ID] { + resolved = append(resolved, pc) + } else { + remaining = append(remaining, pc) + } + } + if len(resolved) > 0 { + toolCalls := make([]dto.ToolCallRequest, 0, len(resolved)) + for _, pc := range resolved { + args := pc.Args + if args == "" { + args = "{}" + } + toolCalls = append(toolCalls, dto.ToolCallRequest{ + ID: pc.ID, + Type: "function", + Function: dto.FunctionRequest{ + Name: pc.Name, + Arguments: args, + }, + }) + } + msg := dto.Message{ + Role: "assistant", + Content: nil, + } + msg.SetToolCalls(toolCalls) + // DeepSeek thinking mode requires reasoning_content on tool call messages + reasoningText := pendingReasoning + if reasoningText == "" { + reasoningText = "Tool calls." + } + msg.ReasoningContent = &reasoningText + pendingReasoning = "" + messages = append(messages, msg) + } + pendingCalls = remaining + } + + for _, item := range items { + itemType, _ := item["type"].(string) + + switch itemType { + case "reasoning": + // Cache reasoning text, attach to next assistant message + content, _ := item["content"].([]any) + var texts []string + for _, partAny := range content { + part, ok := partAny.(map[string]any) + if !ok { + continue + } + if txt, ok := part["text"].(string); ok && txt != "" { + texts = append(texts, txt) + } + } + if len(texts) > 0 { + pendingReasoning = strings.Join(texts, "\n") + } + + case "function_call": + callID, _ := item["call_id"].(string) + if callID == "" { + callID, _ = item["id"].(string) + } + if callID == "" { + continue + } + name, _ := item["name"].(string) + args, _ := item["arguments"].(string) + pendingCalls = append(pendingCalls, pendingCall{ + ID: callID, + Name: name, + Args: args, + }) + + case "function_call_output": + callID, _ := item["call_id"].(string) + if callID == "" { + continue + } + respondedIDs[callID] = true + flushPendingCalls() + + output := item["output"] + outputStr := "" + switch v := output.(type) { + case string: + outputStr = v + default: + if b, err := common.Marshal(output); err == nil { + outputStr = string(b) + } + } + messages = append(messages, dto.Message{ + Role: "tool", + Content: outputStr, + ToolCallId: callID, + }) + + default: + // Flush pending calls before non-function_call messages + flushPendingCalls() + + role, _ := item["role"].(string) + role = normalizeResponsesRole(role) + if role == "" { + continue + } + + content := item["content"] + msg := dto.Message{Role: role} + + // name field + if n, ok := item["name"].(string); ok && n != "" { + msg.Name = &n + } + + // tool_call_id + if tcid, ok := item["tool_call_id"].(string); ok && tcid != "" { + msg.ToolCallId = tcid + } + + switch v := content.(type) { + case string: + msg.Content = v + case []any: + mediaContents := make([]dto.MediaContent, 0, len(v)) + for _, partAny := range v { + part, ok := partAny.(map[string]any) + if !ok { + continue + } + partType, _ := part["type"].(string) + switch partType { + case "input_text", "output_text": + text, _ := part["text"].(string) + mediaContents = append(mediaContents, dto.MediaContent{ + Type: dto.ContentTypeText, + Text: text, + }) + case "input_image": + mediaContents = append(mediaContents, dto.MediaContent{ + Type: dto.ContentTypeImageURL, + ImageUrl: normalizeResponsesImageURL(part), + }) + case "input_audio": + mediaContents = append(mediaContents, dto.MediaContent{ + Type: dto.ContentTypeInputAudio, + InputAudio: part["input_audio"], + }) + case "input_file": + mediaContents = append(mediaContents, dto.MediaContent{ + Type: dto.ContentTypeFile, + File: part["file"], + }) + case "input_video": + mediaContents = append(mediaContents, dto.MediaContent{ + Type: dto.ContentTypeVideoUrl, + VideoUrl: part["video_url"], + }) + default: + text, _ := part["text"].(string) + mediaContents = append(mediaContents, dto.MediaContent{ + Type: partType, + Text: text, + }) + } + } + if len(mediaContents) == 1 && mediaContents[0].Type == dto.ContentTypeText { + msg.Content = mediaContents[0].Text + } else { + msg.Content = mediaContents + } + default: + if content != nil { + if b, err := common.Marshal(content); err == nil { + msg.Content = string(b) + } + } + } + + // Attach cached reasoning to assistant message + if role == "assistant" && pendingReasoning != "" { + msg.ReasoningContent = &pendingReasoning + pendingReasoning = "" + } + + // tool_calls from the input item itself (legacy format) + if tcRaw, ok := item["tool_calls"]; ok { + if tcBytes, err := common.Marshal(tcRaw); err == nil { + msg.ToolCalls = tcBytes + } + } + + messages = append(messages, msg) + } + } + + // Flush remaining pending calls + flushPendingCalls() + + // If there are still unresolved function_calls (no matching output seen), + // flush them as an assistant message with tool_calls + if len(pendingCalls) > 0 { + toolCalls := make([]dto.ToolCallRequest, 0, len(pendingCalls)) + for _, pc := range pendingCalls { + args := pc.Args + if args == "" { + args = "{}" + } + toolCalls = append(toolCalls, dto.ToolCallRequest{ + ID: pc.ID, + Type: "function", + Function: dto.FunctionRequest{ + Name: pc.Name, + Arguments: args, + }, + }) + } + msg := dto.Message{ + Role: "assistant", + Content: "", + } + msg.SetToolCalls(toolCalls) + messages = append(messages, msg) + pendingCalls = nil + } + + // Trailing pending reasoning -> last assistant message + if pendingReasoning != "" { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "assistant" && messages[i].ReasoningContent == nil { + messages[i].ReasoningContent = &pendingReasoning + break + } + } + pendingReasoning = "" + } + + return messages, nil +} + +// convertResponsesToolsToChatTools 将 Responses API 的 tools 格式转为 Chat Completions 格式。 +// Responses 格式:{"type":"function","name":"X","description":"...","parameters":{...},"strict":true} +// Chat 格式: {"type":"function","function":{"name":"X","description":"...","parameters":{...},"strict":true}} +// 同时过滤掉非 function 类型的工具(如 web_search_preview、file_search 等), +// 因为 Chat Completions 只支持 function 类型工具。 +func convertResponsesToolsToChatTools(tools json.RawMessage) ([]dto.ToolCallRequest, error) { + if tools == nil { + return nil, nil + } + + var items []map[string]any + if err := common.Unmarshal(tools, &items); err != nil { + return nil, fmt.Errorf("failed to unmarshal tools: %w", err) + } + + chatTools := make([]dto.ToolCallRequest, 0, len(items)) + for _, item := range items { + itemType, _ := item["type"].(string) + if itemType != "function" { + continue + } + name, _ := item["name"].(string) + + // 过滤空 name 的 function tool:上游(如 DeepSeek)要求 function.name 最小长度为 1, + // 空 name 会导致 400 错误 "Invalid 'tools[0].function.name': empty string" + if strings.TrimSpace(name) == "" { + continue + } + + desc, _ := item["description"].(string) + params := item["parameters"] + var strict *bool + if s, ok := item["strict"].(bool); ok { + strict = &s + } + + // Normalize parameters: ensure it's a valid JSON Schema with type:"object" and properties + params = normalizeToolParameters(params) + + chatTools = append(chatTools, dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{Name: name, Description: desc, Parameters: params, Strict: strict}, + }) + } + + return chatTools, nil +} + +// normalizeToolParameters ensures the tool parameters conform to the expected JSON Schema format +// with type:"object" and a properties field, as required by most Chat Completions providers. +func normalizeToolParameters(params any) any { + if params == nil { + return map[string]any{"type": "object", "properties": map[string]any{}} + } + pMap, ok := params.(map[string]any) + if !ok { + return map[string]any{"type": "object", "properties": map[string]any{}} + } + pType, _ := pMap["type"].(string) + if pType == "" { + pMap["type"] = "object" + } + if _, hasProps := pMap["properties"]; !hasProps { + pMap["properties"] = map[string]any{} + } + return pMap +} + +// convertResponsesToolChoiceToChatToolChoice 将 tool_choice 从 Responses 格式转为 Chat 格式。 +// Responses: {"type":"function","name":"X"} → Chat: {"type":"function","function":{"name":"X"}} +// 字符串值("auto"、"none"、"required")直接透传。 +func convertResponsesToolChoiceToChatToolChoice(toolChoice json.RawMessage) any { + if toolChoice == nil { + return nil + } + + // Try string first + if common.GetJsonType(toolChoice) == "string" { + var str string + _ = common.Unmarshal(toolChoice, &str) + return str + } + + // Try object + var m map[string]any + if err := common.Unmarshal(toolChoice, &m); err != nil { + return toolChoice + } + + t, _ := m["type"].(string) + switch t { + case "function": + // Responses: {"type":"function","name":"X"} → Chat: {"type":"function","function":{"name":"X"}} + name, _ := m["name"].(string) + if name != "" { + return map[string]any{ + "type": "function", + "function": map[string]any{"name": name}, + } + } + return toolChoice + default: + return toolChoice + } +} + +func convertResponsesTextToResponseFormat(text json.RawMessage) *dto.ResponseFormat { + if text == nil { + return nil + } + + var textObj map[string]any + if err := common.Unmarshal(text, &textObj); err != nil { + return nil + } + + formatAny, ok := textObj["format"] + if !ok { + return nil + } + + formatMap, ok := formatAny.(map[string]any) + if !ok { + return nil + } + + formatType, _ := formatMap["type"].(string) + if formatType == "" { + return nil + } + + rf := &dto.ResponseFormat{Type: formatType} + + if formatType == "json_schema" { + schemaMap := make(map[string]any) + for k, v := range formatMap { + if k == "type" { + continue + } + schemaMap[k] = v + } + if len(schemaMap) > 0 { + schemaJSON, err := common.Marshal(schemaMap) + if err == nil { + rf.JsonSchema = schemaJSON + } + } + } + + return rf +} + +// normalizeResponsesImageURL handles both direct image_url fields and source.base64 format. +func normalizeResponsesImageURL(part map[string]any) any { + // Try direct image_url or url fields first + if imgURL, ok := part["image_url"]; ok && imgURL != nil { + return normalizeImageURLValue(imgURL) + } + if url, ok := part["url"]; ok && url != nil { + return normalizeImageURLValue(url) + } + // Try source.base64 format + if source, ok := part["source"].(map[string]any); ok { + if sType, _ := source["type"].(string); sType == "base64" { + mediaType, _ := source["media_type"].(string) + data, _ := source["data"].(string) + if mediaType != "" && data != "" { + return &dto.MessageImageUrl{Url: "data:" + mediaType + ";base64," + data} + } + } + } + return nil +} + +func normalizeImageURLValue(v any) any { + switch vv := v.(type) { + case string: + return &dto.MessageImageUrl{Url: vv} + case map[string]any: + url, _ := vv["url"].(string) + detail, _ := vv["detail"].(string) + return &dto.MessageImageUrl{Url: url, Detail: lo.CoalesceOrEmpty(detail, "high")} + default: + return v + } +} + +func normalizeResponsesRole(role string) string { + switch role { + case "developer": + return "system" + default: + return role + } +} diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go index d0c4d312893c..f57a0c7e76ef 100644 --- a/setting/model_setting/global.go +++ b/setting/model_setting/global.go @@ -15,6 +15,31 @@ type ChatCompletionsToResponsesPolicy struct { ModelPatterns []string `json:"model_patterns,omitempty"` } +type ResponsesToChatCompletionsPolicy struct { + Enabled bool `json:"enabled"` + AllChannels bool `json:"all_channels"` + ChannelIDs []int `json:"channel_ids,omitempty"` + ChannelTypes []int `json:"channel_types,omitempty"` + ModelPatterns []string `json:"model_patterns,omitempty"` +} + +func (p ResponsesToChatCompletionsPolicy) IsChannelEnabled(channelID int, channelType int) bool { + if !p.Enabled { + return false + } + if p.AllChannels { + return true + } + + if channelID > 0 && len(p.ChannelIDs) > 0 && slices.Contains(p.ChannelIDs, channelID) { + return true + } + if channelType > 0 && len(p.ChannelTypes) > 0 && slices.Contains(p.ChannelTypes, channelType) { + return true + } + return false +} + func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channelType int) bool { if !p.Enabled { return false @@ -35,7 +60,8 @@ func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channe type GlobalSettings struct { PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` ThinkingModelBlacklist []string `json:"thinking_model_blacklist"` - ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` + ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"` + ResponsesToChatCompletionsPolicy ResponsesToChatCompletionsPolicy `json:"responses_to_chat_completions_policy"` } // 默认配置 @@ -49,6 +75,10 @@ var defaultOpenaiSettings = GlobalSettings{ Enabled: false, AllChannels: true, }, + ResponsesToChatCompletionsPolicy: ResponsesToChatCompletionsPolicy{ + Enabled: false, + AllChannels: true, + }, } // 全局实例 diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 17511d2a5552..271c9f50a8c3 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -107,6 +107,7 @@ "Changing batch type to:": "Changing batch type to:", "ChatCompletions→Responses 兼容配置": "ChatCompletions→Responses Compatibility Configuration", "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses Compatibility (Beta)", + "Responses→ChatCompletions 兼容配置": "Responses→ChatCompletions Compatibility Configuration", "Claude 强制 beta=true": "Claude Force beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude appends these values on top of existing request headers. Existing headers are not overwritten, and duplicate values are ignored automatically.", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage", diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index a24d32bad00c..00779805f624 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -109,6 +109,7 @@ "Changing batch type to:": "Changement du type de lot en :", "ChatCompletions→Responses 兼容配置": "Configuration de compatibilité ChatCompletions→Responses", "ChatCompletions→Responses 兼容配置(Beta)": "Compatibilité ChatCompletions→Responses (bêta)", + "Responses→ChatCompletions 兼容配置": "Compatibilité Responses→ChatCompletions", "Claude 强制 beta=true": "Claude forcer beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude ajoute ces valeurs aux en-tetes de requete existants. Les en-tetes existants ne sont pas remplaces et les valeurs en double sont ignorees automatiquement.", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Adaptation de la pensée Claude BudgetTokens = MaxTokens * BudgetTokens pourcentage", diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index dde2a1a578e2..24e77d47a34e 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -103,6 +103,7 @@ "Changing batch type to:": "Changing batch type to:", "ChatCompletions→Responses 兼容配置": "ChatCompletions→Responses 互換設定", "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 互換設定(ベータ)", + "Responses→ChatCompletions 兼容配置": "Responses→ChatCompletions 互換設定", "Claude 强制 beta=true": "Claude 強制 beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude は既存のリクエストヘッダーにこれらの値を追加します。既存の同名ヘッダーは上書きされず、重複した値は自動的に無視されます。", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考モード:BudgetTokens = MaxTokens * BudgetTokensの割合", diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index b934dfe1bc5c..3a2cdba10c13 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -113,6 +113,7 @@ "Changing batch type to:": "Изменение типа пакета на:", "ChatCompletions→Responses 兼容配置": "Настройка совместимости ChatCompletions→Responses", "ChatCompletions→Responses 兼容配置(Beta)": "Совместимость ChatCompletions→Responses (бета)", + "Responses→ChatCompletions 兼容配置": "Совместимость Responses→ChatCompletions", "Claude 强制 beta=true": "Claude принудительно beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude добавляет эти значения поверх существующих заголовков запроса. Уже существующие заголовки не перезаписываются, а дублирующиеся значения автоматически игнорируются.", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Адаптация мышления Claude BudgetTokens = MaxTokens * процент BudgetTokens", diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 771a25fcf201..80b640556cde 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -103,6 +103,7 @@ "Changing batch type to:": "Đang thay đổi loại hàng loạt thành:", "ChatCompletions→Responses 兼容配置": "Cấu hình tương thích ChatCompletions→Responses", "ChatCompletions→Responses 兼容配置(Beta)": "Tương thích ChatCompletions→Responses (Beta)", + "Responses→ChatCompletions 兼容配置": "Tương thích Responses→ChatCompletions", "Claude 强制 beta=true": "Claude buộc beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude sẽ thêm các giá trị này vào các tiêu đề yêu cầu hiện có. Các tiêu đề cùng tên sẽ không bị ghi đè và các giá trị trùng lặp sẽ tự động bị bỏ qua.", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Thích ứng tư duy Claude BudgetTokens = MaxTokens * Tỷ lệ phần trăm BudgetTokens", diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index e1141b0414f7..09c49e19cbc7 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -96,6 +96,7 @@ "Changing batch type to:": "Changing batch type to:", "ChatCompletions→Responses 兼容配置": "ChatCompletions→Responses 兼容配置", "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)", + "Responses→ChatCompletions 兼容配置": "Responses→ChatCompletions 兼容配置", "Claude 强制 beta=true": "Claude 强制 beta=true", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比", diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json index 3be48fb4dce5..b32ac3949d0b 100644 --- a/web/classic/src/i18n/locales/zh-TW.json +++ b/web/classic/src/i18n/locales/zh-TW.json @@ -102,6 +102,7 @@ "Changing batch type to:": "Changing batch type to:", "ChatCompletions→Responses 兼容配置": "", "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容設定(Beta)", + "Responses→ChatCompletions 兼容配置": "Responses→ChatCompletions 兼容設定", "Claude 强制 beta=true": "", "Claude会在原有请求头基础上追加这些值,不会覆盖已有同名请求头;重复值会自动忽略。": "Claude會在原有請求頭基礎上追加這些值,不會覆蓋已有同名請求頭;重複值會自動忽略。", "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude思考相容 BudgetTokens = MaxTokens * BudgetTokens 百分比", diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index b70e8ffb955c..30ca0f442cb8 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -2584,6 +2584,7 @@ "签到奖励的最大额度": "签到奖励的最大额度", "保存签到设置": "保存签到设置", "ChatCompletions→Responses 兼容配置(Beta)": "ChatCompletions→Responses 兼容配置(Beta)", + "Responses→ChatCompletions 兼容配置": "Responses→ChatCompletions 兼容配置", "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。": "提示:该功能为测试版,未来配置结构与功能行为可能发生变更,请勿在生产环境使用。", "填充模板(指定渠道)": "填充模板(指定渠道)", "填充模板(全渠道)": "填充模板(全渠道)", diff --git a/web/classic/src/pages/Setting/Model/SettingGlobalModel.jsx b/web/classic/src/pages/Setting/Model/SettingGlobalModel.jsx index 4b8f9f4d93fa..6db18af2f09c 100644 --- a/web/classic/src/pages/Setting/Model/SettingGlobalModel.jsx +++ b/web/classic/src/pages/Setting/Model/SettingGlobalModel.jsx @@ -66,10 +66,33 @@ const chatCompletionsToResponsesPolicyAllChannelsExample = JSON.stringify( 2, ); +const responsesToChatCompletionsPolicyExample = JSON.stringify( + { + enabled: true, + all_channels: false, + channel_ids: [1, 2], + channel_types: [43, 16, 26], + model_patterns: ['^deepseek-.*$', '^glm-.*$'], + }, + null, + 2, +); + +const responsesToChatCompletionsPolicyAllChannelsExample = JSON.stringify( + { + enabled: true, + all_channels: true, + model_patterns: ['^deepseek-.*$', '^glm-.*$'], + }, + null, + 2, +); + const defaultGlobalSettingInputs = { 'global.pass_through_request_enabled': false, 'global.thinking_model_blacklist': '[]', 'global.chat_completions_to_responses_policy': '{}', + 'global.responses_to_chat_completions_policy': '{}', 'general_setting.ping_interval_enabled': false, 'general_setting.ping_interval_seconds': 60, }; @@ -83,6 +106,8 @@ export default function SettingGlobalModel(props) { const [inputsRow, setInputsRow] = useState(defaultGlobalSettingInputs); const chatCompletionsToResponsesPolicyKey = 'global.chat_completions_to_responses_policy'; + const responsesToChatCompletionsPolicyKey = + 'global.responses_to_chat_completions_policy'; const setChatCompletionsToResponsesPolicyValue = (value) => { setInputs((prev) => ({ @@ -94,12 +119,23 @@ export default function SettingGlobalModel(props) { } }; + const setResponsesToChatCompletionsPolicyValue = (value) => { + setInputs((prev) => ({ + ...prev, + [responsesToChatCompletionsPolicyKey]: value, + })); + if (refForm.current) { + refForm.current.setValue(responsesToChatCompletionsPolicyKey, value); + } + }; + const normalizeValueBeforeSave = (key, value) => { if (key === 'global.thinking_model_blacklist') { const text = typeof value === 'string' ? value.trim() : ''; return text === '' ? '[]' : value; } - if (key === 'global.chat_completions_to_responses_policy') { + if (key === 'global.chat_completions_to_responses_policy' || + key === 'global.responses_to_chat_completions_policy') { const text = typeof value === 'string' ? value.trim() : ''; return text === '' ? '{}' : value; } @@ -156,7 +192,8 @@ export default function SettingGlobalModel(props) { value = defaultGlobalSettingInputs[key]; } } - if (key === 'global.chat_completions_to_responses_policy') { + if (key === 'global.chat_completions_to_responses_policy' || + key === 'global.responses_to_chat_completions_policy') { try { value = value && String(value).trim() !== '' @@ -288,6 +325,9 @@ export default function SettingGlobalModel(props) { message: t('不是合法的 JSON 字符串'), }, ]} + extraText={t( + 'model_patterns 支持正则匹配模型名称,例如 ["^deepseek-.*$", "^glm-.*$"],留空表示匹配所有模型', + )} onChange={(value) => setInputs((prev) => ({ ...prev, @@ -355,6 +395,130 @@ export default function SettingGlobalModel(props) { + + {t('Responses→ChatCompletions 兼容配置')} + + 测试版 + + + } + > + + + + + + + + + { + if (!value || value.trim() === '') return true; + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + extraText={t( + 'model_patterns 支持正则匹配模型名称,例如 ["^deepseek-.*$", "^glm-.*$"],留空表示匹配所有模型', + )} + onChange={(value) => + setInputs((prev) => ({ + ...prev, + [responsesToChatCompletionsPolicyKey]: value, + })) + } + /> + + + + + +
+ + + +
+ +
+
+ diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index 5e9cee02b800..35f5b08d796d 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -160,6 +160,7 @@ export function ModelMutateDrawer({ 'global.pass_through_request_enabled': false, 'global.thinking_model_blacklist': '[]', 'global.chat_completions_to_responses_policy': '{}', + 'global.responses_to_chat_completions_policy': '{}', 'general_setting.ping_interval_enabled': false, 'general_setting.ping_interval_seconds': 60, 'gemini.safety_settings': '', diff --git a/web/default/src/features/system-settings/models/global-settings-card.tsx b/web/default/src/features/system-settings/models/global-settings-card.tsx index ba6b59d3972d..0d021c17eb4e 100644 --- a/web/default/src/features/system-settings/models/global-settings-card.tsx +++ b/web/default/src/features/system-settings/models/global-settings-card.tsx @@ -74,6 +74,27 @@ const chatToResponsesPolicyAllChannelsExample = JSON.stringify( 2 ) +const responsesToChatCompletionsPolicyExample = JSON.stringify( + { + enabled: true, + all_channels: false, + channel_ids: [1, 2], + model_patterns: ['^deepseek-.*$', '^glm-.*$'], + }, + null, + 2 +) + +const responsesToChatCompletionsPolicyAllChannelsExample = JSON.stringify( + { + enabled: true, + all_channels: true, + model_patterns: ['^deepseek-.*$', '^glm-.*$'], + }, + null, + 2 +) + const jsonString = z.string().refine((value) => { const trimmed = value.trim() if (!trimmed) return true @@ -90,6 +111,7 @@ const schema = z.object({ pass_through_request_enabled: z.boolean(), thinking_model_blacklist: jsonString, chat_completions_to_responses_policy: jsonString, + responses_to_chat_completions_policy: jsonString, }), general_setting: z.object({ ping_interval_enabled: z.boolean(), @@ -104,6 +126,7 @@ type FlatGlobalModelSettings = { 'global.pass_through_request_enabled': boolean 'global.thinking_model_blacklist': string 'global.chat_completions_to_responses_policy': string + 'global.responses_to_chat_completions_policy': string 'general_setting.ping_interval_enabled': boolean 'general_setting.ping_interval_seconds': number } @@ -121,6 +144,10 @@ const flattenGlobalValues = ( values.global.chat_completions_to_responses_policy, '{}' ), + 'global.responses_to_chat_completions_policy': normalizeJsonText( + values.global.responses_to_chat_completions_policy, + '{}' + ), 'general_setting.ping_interval_enabled': values.general_setting.ping_interval_enabled, 'general_setting.ping_interval_seconds': @@ -159,6 +186,7 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) { field: | 'global.thinking_model_blacklist' | 'global.chat_completions_to_responses_policy' + | 'global.responses_to_chat_completions_policy' ) => { const raw = form.getValues(field) if (!raw || !raw.trim()) return @@ -297,6 +325,9 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) { {t('Empty value will be saved as {}.')} + + {t('Use model_patterns to match specific models by regex, e.g. ["^gpt-4o.*$"]. Leave empty to match all models.')} +