mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
* fix(openai): harden Chat-to-Responses compatibility Add a shared Responses-to-Chat stream state machine and use it from the OpenAI relay path. Preserve assistant text alongside tool calls, bind tool argument deltas by output_index, map incomplete finish reasons, support reasoning/custom tool events, and buffer upstream SSE for non-stream Chat clients. Add deterministic service tests and relay SSE tests for the conversion path. Related to #5745. * refactor: rename openaicompat to relayconvert for improved clarity * feat(gemini): support responses request conversion * feat: add responses to chat conversion support * fix: harden responses chat conversion edge cases
289 lines
9.7 KiB
Go
289 lines
9.7 KiB
Go
package openai
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/dto"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relay/helper"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
|
"github.com/QuantumNous/new-api/types"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
|
if resp == nil || resp.Body == nil {
|
|
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
}
|
|
|
|
defer service.CloseResponseBodyGracefully(resp)
|
|
|
|
var responsesResp dto.OpenAIResponsesResponse
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
|
}
|
|
|
|
if err := common.Unmarshal(body, &responsesResp); err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
}
|
|
|
|
if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
|
}
|
|
|
|
chatId := helper.GetResponseID(c)
|
|
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
}
|
|
|
|
if usage == nil || usage.TotalTokens == 0 {
|
|
text := service.ExtractOutputTextFromResponses(&responsesResp)
|
|
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
|
chatResp.Usage = *usage
|
|
}
|
|
|
|
var responseBody []byte
|
|
switch info.RelayFormat {
|
|
case types.RelayFormatClaude:
|
|
claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
|
|
responseBody, err = common.Marshal(claudeResp)
|
|
case types.RelayFormatGemini:
|
|
geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
|
|
responseBody, err = common.Marshal(geminiResp)
|
|
default:
|
|
responseBody, err = common.Marshal(chatResp)
|
|
}
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
|
}
|
|
|
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
|
return usage, nil
|
|
}
|
|
|
|
func OaiResponsesToChatBufferedStreamHandler(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)
|
|
|
|
accumulator := relayconvert.NewResponsesBufferedAccumulator()
|
|
var finalResponse *dto.OpenAIResponsesResponse
|
|
var streamErr *types.NewAPIError
|
|
|
|
scanner := helper.NewStreamScanner(resp.Body)
|
|
scanner.Split(bufio.ScanLines)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if len(line) < 6 || line[:5] != "data:" {
|
|
continue
|
|
}
|
|
data := line[5:]
|
|
data = strings.TrimSpace(data)
|
|
if data == "" || data == "[DONE]" {
|
|
if data == "[DONE]" {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
|
|
var streamResp dto.ResponsesStreamResponse
|
|
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
|
|
logger.LogError(c, "failed to unmarshal buffered responses stream event: "+err.Error())
|
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
break
|
|
}
|
|
accumulator.ProcessEvent(&streamResp)
|
|
switch streamResp.Type {
|
|
case "response.completed", "response.done", "response.incomplete":
|
|
finalResponse = streamResp.Response
|
|
if streamResp.Type == "response.incomplete" {
|
|
if finalResponse == nil {
|
|
finalResponse = &dto.OpenAIResponsesResponse{}
|
|
}
|
|
if len(finalResponse.Status) == 0 {
|
|
finalResponse.Status = []byte(`"incomplete"`)
|
|
}
|
|
}
|
|
case "response.failed", "response.error":
|
|
if streamResp.Response != nil {
|
|
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
|
|
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
|
|
break
|
|
}
|
|
}
|
|
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
}
|
|
if streamErr != nil || finalResponse != nil {
|
|
break
|
|
}
|
|
}
|
|
if streamErr != nil {
|
|
return nil, streamErr
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
}
|
|
if finalResponse == nil {
|
|
finalResponse = &dto.OpenAIResponsesResponse{
|
|
ID: helper.GetResponseID(c),
|
|
CreatedAt: int(time.Now().Unix()),
|
|
Model: info.UpstreamModelName,
|
|
Status: []byte(`"completed"`),
|
|
}
|
|
}
|
|
accumulator.SupplementResponseOutput(finalResponse)
|
|
|
|
chatId := helper.GetResponseID(c)
|
|
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(finalResponse, chatId)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
}
|
|
if usage == nil || usage.TotalTokens == 0 {
|
|
text := service.ExtractOutputTextFromResponses(finalResponse)
|
|
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
|
chatResp.Usage = *usage
|
|
}
|
|
|
|
var responseBody []byte
|
|
switch info.RelayFormat {
|
|
case types.RelayFormatClaude:
|
|
claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
|
|
responseBody, err = common.Marshal(claudeResp)
|
|
case types.RelayFormatGemini:
|
|
geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
|
|
responseBody, err = common.Marshal(geminiResp)
|
|
default:
|
|
responseBody, err = common.Marshal(chatResp)
|
|
}
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
|
}
|
|
|
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
|
return usage, nil
|
|
}
|
|
|
|
func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
|
if resp == nil || resp.Body == nil {
|
|
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
}
|
|
|
|
defer service.CloseResponseBodyGracefully(resp)
|
|
|
|
responseId := helper.GetResponseID(c)
|
|
createAt := time.Now().Unix()
|
|
state := relayconvert.NewResponsesToChatStreamState(info.UpstreamModelName, false)
|
|
state.ID = responseId
|
|
state.Created = createAt
|
|
streamErr := (*types.NewAPIError)(nil)
|
|
|
|
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
|
|
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
|
|
}
|
|
|
|
sendChatChunk := func(chunk dto.ChatCompletionsStreamResponse) bool {
|
|
if len(chunk.Choices) == 0 && chunk.Usage == nil {
|
|
return true
|
|
}
|
|
if info.RelayFormat == types.RelayFormatOpenAI {
|
|
if err := helper.ObjectData(c, &chunk); err != nil {
|
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
chunkData, err := common.Marshal(&chunk)
|
|
if err != nil {
|
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
if err := HandleStreamFormat(c, info, string(chunkData), false, false); err != nil {
|
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
|
if streamErr != nil {
|
|
sr.Stop(streamErr)
|
|
return
|
|
}
|
|
|
|
var streamResp dto.ResponsesStreamResponse
|
|
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
|
|
logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
|
|
sr.Error(err)
|
|
return
|
|
}
|
|
|
|
if streamResp.Type == "response.error" || streamResp.Type == "response.failed" {
|
|
if streamResp.Response != nil {
|
|
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
|
|
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
|
|
sr.Stop(streamErr)
|
|
return
|
|
}
|
|
}
|
|
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
sr.Stop(streamErr)
|
|
return
|
|
}
|
|
|
|
chunks, err := relayconvert.ResponsesStreamEventToChatChunks(&streamResp, state)
|
|
if err != nil {
|
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
sr.Stop(streamErr)
|
|
return
|
|
}
|
|
for _, chunk := range chunks {
|
|
if !sendChatChunk(chunk) {
|
|
sr.Stop(streamErr)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
if streamErr != nil {
|
|
return nil, streamErr
|
|
}
|
|
|
|
usage := state.Usage
|
|
if usage.TotalTokens == 0 {
|
|
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
|
state.Usage = usage
|
|
}
|
|
|
|
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
|
info.ClaudeConvertInfo.Usage = usage
|
|
}
|
|
for _, chunk := range relayconvert.FinalizeResponsesToChatStream(state) {
|
|
if !sendChatChunk(chunk) {
|
|
return nil, streamErr
|
|
}
|
|
}
|
|
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
|
|
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, state.Created, state.Model, *usage)); err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
if info.RelayFormat == types.RelayFormatOpenAI {
|
|
helper.Done(c)
|
|
}
|
|
return usage, nil
|
|
}
|