From 2de5a21bb7fd93db973e461fabe829bc1b0af909 Mon Sep 17 00:00:00 2001 From: Roson Date: Sun, 31 May 2026 11:59:18 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Responses?= =?UTF-8?q?=E2=86=92ChatCompletions=20=E5=8D=8F=E8=AE=AE=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增协议转换引擎,将 /v1/responses 请求转为 ChatCompletions 协议: - ResponsesRequestToChatCompletionsRequest 请求体转换 - ChatCompletionsResponseToResponsesResponse 非流式响应转换 - OaiChatToResponsesStreamHandler 流式 SSE 转换 - responsesViaChatCompletions 编排函数 Co-Authored-By: Claude Opus 4.7 --- .../openai/chat_to_responses_stream.go | 559 +++++++++++++++++ relay/responses_via_chat_completions.go | 120 ++++ .../chat_to_responses_response.go | 150 +++++ .../openaicompat/responses_to_chat_request.go | 583 ++++++++++++++++++ 4 files changed, 1412 insertions(+) create mode 100644 relay/channel/openai/chat_to_responses_stream.go create mode 100644 relay/responses_via_chat_completions.go create mode 100644 service/openaicompat/chat_to_responses_response.go create mode 100644 service/openaicompat/responses_to_chat_request.go 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..8e877ee6765b --- /dev/null +++ b/relay/channel/openai/chat_to_responses_stream.go @@ -0,0 +1,559 @@ +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 converts a non-stream ChatCompletions response to Responses format. +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 tracks a single function_call output item being built from stream chunks. +type toolCallState struct { + callID string + name string + args string + itemIdx int + nameDone bool + addedEmitted bool +} + +// OaiChatToResponsesStreamHandler converts a streaming ChatCompletions response to Responses SSE format. +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_via_chat_completions.go b/relay/responses_via_chat_completions.go new file mode 100644 index 000000000000..126cd3512f8a --- /dev/null +++ b/relay/responses_via_chat_completions.go @@ -0,0 +1,120 @@ +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" +) + +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/openaicompat/chat_to_responses_response.go b/service/openaicompat/chat_to_responses_response.go new file mode 100644 index 000000000000..a3615368766b --- /dev/null +++ b/service/openaicompat/chat_to_responses_response.go @@ -0,0 +1,150 @@ +package openaicompat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" +) + +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/responses_to_chat_request.go b/service/openaicompat/responses_to_chat_request.go new file mode 100644 index 000000000000..adf1091582b4 --- /dev/null +++ b/service/openaicompat/responses_to_chat_request.go @@ -0,0 +1,583 @@ +package openaicompat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/samber/lo" +) + +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 tracks function_call items that need to be flushed. +type pendingCall struct { + ID string + Name string + Args string +} + +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 +} + +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) + 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 +} + +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 + } +} From 2ae544685402afb83ded5342c0c7b49c9ea5822d Mon Sep 17 00:00:00 2001 From: Roson Date: Sun, 31 May 2026 11:59:27 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Responses=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E9=99=8D=E7=BA=A7=E7=AD=96=E7=95=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ResponsesToChatCompletionsPolicy 策略结构体 - 新增 ShouldResponsesUseChatCompletionsGlobal 策略评估 - 在 ResponsesHelper 中插入策略检查和降级分发 Co-Authored-By: Claude Opus 4.7 --- relay/responses_handler.go | 16 +++++++++++++ service/openai_chat_responses_compat.go | 8 +++++++ service/openai_chat_responses_mode.go | 8 +++++++ service/openaicompat/policy.go | 19 +++++++++++++++ setting/model_setting/global.go | 32 ++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 1 deletion(-) 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/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/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/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, + }, } // 全局实例 From 2a28e92d4bef328da71484b7641118cac90db63f Mon Sep 17 00:00:00 2001 From: Roson Date: Sun, 31 May 2026 11:59:32 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20strict=20=E5=AD=97=E6=AE=B5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FunctionRequest 新增 Strict 字段用于工具参数透传 - 前端默认主题从 classic 改为 default Co-Authored-By: Claude Opus 4.7 --- common/constants.go | 2 +- dto/openai_request.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/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"` } From c320623e7cd2c11a44850a35aa6641d7a435314e Mon Sep 17 00:00:00 2001 From: Roson Date: Sun, 31 May 2026 11:59:39 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(web):=20default=20=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Responses=E2=86=92ChatCompletions=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在系统设置的 Global Model Configuration 中添加协议降级策略配置区块, 支持通过 JSON 配置 enabled、all_channels、channel_ids、model_patterns 等参数 Co-Authored-By: Claude Opus 4.7 --- .../drawers/model-mutate-drawer.tsx | 1 + .../models/global-settings-card.tsx | 122 ++++++++++++++++++ .../features/system-settings/models/index.tsx | 1 + .../models/section-registry.tsx | 4 + .../src/features/system-settings/types.ts | 1 + 5 files changed, 129 insertions(+) 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.')} +