fix(openai): harden Chat-to-Responses compatibility (#5772)

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.
This commit is contained in:
Calcium-Ion
2026-06-27 19:16:56 +08:00
committed by GitHub
parent 626dadb556
commit 3a506f50f0
9 changed files with 1516 additions and 470 deletions
+134 -396
View File
@@ -1,6 +1,7 @@
package openai
import (
"bufio"
"fmt"
"io"
"net/http"
@@ -13,31 +14,12 @@ import (
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/openaicompat"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
func responsesStreamIndexKey(itemID string, idx *int) string {
if itemID == "" {
return ""
}
if idx == nil {
return itemID
}
return fmt.Sprintf("%s:%d", itemID, *idx)
}
func stringDeltaFromPrefix(prev string, next string) string {
if next == "" {
return ""
}
if prev != "" && strings.HasPrefix(next, prev) {
return next[len(prev):]
}
return next
}
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)
@@ -90,6 +72,109 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
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 := openaicompat.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)
@@ -99,44 +184,28 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
responseId := helper.GetResponseID(c)
createAt := time.Now().Unix()
model := info.UpstreamModelName
var (
usage = &dto.Usage{}
outputText strings.Builder
usageText strings.Builder
sentStart bool
sentStop bool
sawToolCall bool
streamErr *types.NewAPIError
)
toolCallIndexByID := make(map[string]int)
toolCallNameByID := make(map[string]string)
toolCallArgsByID := make(map[string]string)
toolCallNameSent := make(map[string]bool)
toolCallCanonicalIDByItemID := make(map[string]string)
hasSentReasoningSummary := false
needsReasoningSummarySeparator := false
//reasoningSummaryTextByKey := make(map[string]string)
state := openaicompat.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 chunk == nil {
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 {
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)
chunkData, err := common.Marshal(&chunk)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
@@ -148,154 +217,6 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
sendStartIfNeeded := func() bool {
if sentStart {
return true
}
if !sendChatChunk(helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)) {
return false
}
sentStart = true
return true
}
//sendReasoningDelta := func(delta string) bool {
// if delta == "" {
// return true
// }
// if !sendStartIfNeeded() {
// return false
// }
//
// usageText.WriteString(delta)
// chunk := &dto.ChatCompletionsStreamResponse{
// Id: responseId,
// Object: "chat.completion.chunk",
// Created: createAt,
// Model: model,
// Choices: []dto.ChatCompletionsStreamResponseChoice{
// {
// Index: 0,
// Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
// ReasoningContent: &delta,
// },
// },
// },
// }
// if err := helper.ObjectData(c, chunk); err != nil {
// streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
// return false
// }
// return true
//}
sendReasoningSummaryDelta := func(delta string) bool {
if delta == "" {
return true
}
if needsReasoningSummarySeparator {
if strings.HasPrefix(delta, "\n\n") {
needsReasoningSummarySeparator = false
} else if strings.HasPrefix(delta, "\n") {
delta = "\n" + delta
needsReasoningSummarySeparator = false
} else {
delta = "\n\n" + delta
needsReasoningSummarySeparator = false
}
}
if !sendStartIfNeeded() {
return false
}
usageText.WriteString(delta)
chunk := &dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
Created: createAt,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 0,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ReasoningContent: &delta,
},
},
},
}
if !sendChatChunk(chunk) {
return false
}
hasSentReasoningSummary = true
return true
}
sendToolCallDelta := func(callID string, name string, argsDelta string) bool {
if callID == "" {
return true
}
if outputText.Len() > 0 {
// Prefer streaming assistant text over tool calls to match non-stream behavior.
return true
}
if !sendStartIfNeeded() {
return false
}
idx, ok := toolCallIndexByID[callID]
if !ok {
idx = len(toolCallIndexByID)
toolCallIndexByID[callID] = idx
}
if name != "" {
toolCallNameByID[callID] = name
}
if toolCallNameByID[callID] != "" {
name = toolCallNameByID[callID]
}
tool := dto.ToolCallResponse{
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: argsDelta,
},
}
tool.SetIndex(idx)
if name != "" && !toolCallNameSent[callID] {
tool.Function.Name = name
toolCallNameSent[callID] = true
}
chunk := &dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
Created: createAt,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 0,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
ToolCalls: []dto.ToolCallResponse{tool},
},
},
},
}
if !sendChatChunk(chunk) {
return false
}
sawToolCall = true
// Include tool call data in the local builder for fallback token estimation.
if tool.Function.Name != "" {
usageText.WriteString(tool.Function.Name)
}
if argsDelta != "" {
usageText.WriteString(argsDelta)
}
return true
}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
sr.Stop(streamErr)
@@ -309,193 +230,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
switch streamResp.Type {
case "response.created":
if streamResp.Response != nil {
if streamResp.Response.Model != "" {
model = streamResp.Response.Model
}
if streamResp.Response.CreatedAt != 0 {
createAt = int64(streamResp.Response.CreatedAt)
}
}
//case "response.reasoning_text.delta":
//if !sendReasoningDelta(streamResp.Delta) {
// sr.Stop(streamErr)
// return
//}
//case "response.reasoning_text.done":
case "response.reasoning_summary_text.delta":
if !sendReasoningSummaryDelta(streamResp.Delta) {
sr.Stop(streamErr)
return
}
case "response.reasoning_summary_text.done":
if hasSentReasoningSummary {
needsReasoningSummarySeparator = true
}
//case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
// key := responsesStreamIndexKey(strings.TrimSpace(streamResp.ItemID), streamResp.SummaryIndex)
// if key == "" || streamResp.Part == nil {
// break
// }
// // Only handle summary text parts, ignore other part types.
// if streamResp.Part.Type != "" && streamResp.Part.Type != "summary_text" {
// break
// }
// prev := reasoningSummaryTextByKey[key]
// next := streamResp.Part.Text
// delta := stringDeltaFromPrefix(prev, next)
// reasoningSummaryTextByKey[key] = next
// if !sendReasoningSummaryDelta(delta) {
// sr.Stop(streamErr)
// return
// }
case "response.output_text.delta":
if !sendStartIfNeeded() {
sr.Stop(streamErr)
return
}
if streamResp.Delta != "" {
outputText.WriteString(streamResp.Delta)
usageText.WriteString(streamResp.Delta)
delta := streamResp.Delta
chunk := &dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
Created: createAt,
Model: model,
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Index: 0,
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: &delta,
},
},
},
}
if !sendChatChunk(chunk) {
sr.Stop(streamErr)
return
}
}
case "response.output_item.added", "response.output_item.done":
if streamResp.Item == nil {
break
}
if streamResp.Item.Type != "function_call" {
break
}
itemID := strings.TrimSpace(streamResp.Item.ID)
callID := strings.TrimSpace(streamResp.Item.CallId)
if callID == "" {
callID = itemID
}
if itemID != "" && callID != "" {
toolCallCanonicalIDByItemID[itemID] = callID
}
name := strings.TrimSpace(streamResp.Item.Name)
if name != "" {
toolCallNameByID[callID] = name
}
newArgs := streamResp.Item.ArgumentsString()
prevArgs := toolCallArgsByID[callID]
argsDelta := ""
if newArgs != "" {
if strings.HasPrefix(newArgs, prevArgs) {
argsDelta = newArgs[len(prevArgs):]
} else {
argsDelta = newArgs
}
toolCallArgsByID[callID] = newArgs
}
if !sendToolCallDelta(callID, name, argsDelta) {
sr.Stop(streamErr)
return
}
case "response.function_call_arguments.delta":
itemID := strings.TrimSpace(streamResp.ItemID)
callID := toolCallCanonicalIDByItemID[itemID]
if callID == "" {
callID = itemID
}
if callID == "" {
break
}
toolCallArgsByID[callID] += streamResp.Delta
if !sendToolCallDelta(callID, "", streamResp.Delta) {
sr.Stop(streamErr)
return
}
case "response.function_call_arguments.done":
case "response.completed":
if streamResp.Response != nil {
if streamResp.Response.Model != "" {
model = streamResp.Response.Model
}
if streamResp.Response.CreatedAt != 0 {
createAt = int64(streamResp.Response.CreatedAt)
}
if streamResp.Response.Usage != nil {
if streamResp.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResp.Response.Usage.InputTokens
usage.InputTokens = streamResp.Response.Usage.InputTokens
}
if streamResp.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResp.Response.Usage.OutputTokens
usage.OutputTokens = streamResp.Response.Usage.OutputTokens
}
if streamResp.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResp.Response.Usage.TotalTokens
} else {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
if streamResp.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens
usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens
}
if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 {
usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens
}
}
}
if !sendStartIfNeeded() {
sr.Stop(streamErr)
return
}
if !sentStop {
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
finishReason := "stop"
if sawToolCall && outputText.Len() == 0 {
finishReason = "tool_calls"
}
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
if !sendChatChunk(stop) {
sr.Stop(streamErr)
return
}
sentStop = true
}
case "response.error", "response.failed":
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)
@@ -506,8 +241,19 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
default:
chunks, err := openaicompat.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
}
}
})
@@ -515,30 +261,22 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, streamErr
}
usage := state.Usage
if usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
state.Usage = usage
}
if !sentStart {
if !sendChatChunk(helper.GenerateStartEmptyResponse(responseId, createAt, model, nil)) {
return nil, streamErr
}
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
if !sentStop {
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
finishReason := "stop"
if sawToolCall && outputText.Len() == 0 {
finishReason = "tool_calls"
}
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
if !sendChatChunk(stop) {
for _, chunk := range openaicompat.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, createAt, model, *usage)); err != 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)
}
}