mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 01:56:53 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newResponsesChatTestContext(t *testing.T, body string, isStream bool) (*gin.Context, *httptest.ResponseRecorder, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
c.Set(common.RequestIdKey, "responses-test")
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"},
|
||||
IsStream: isStream,
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
ShouldIncludeUsage: true,
|
||||
DisablePing: true,
|
||||
}
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
func TestOaiResponsesToChatStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-test","created_at":1710000000}}`,
|
||||
`data: {"type":"response.output_text.delta","delta":"hello"}`,
|
||||
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup"}}`,
|
||||
`data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"q\":\"x\"}"}`,
|
||||
`data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":2,"output_tokens":3,"total_tokens":5}}}`,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newResponsesChatTestContext(t, body, true)
|
||||
|
||||
usage, err := OaiResponsesToChatStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 2, usage.PromptTokens)
|
||||
require.Equal(t, 3, usage.CompletionTokens)
|
||||
require.Equal(t, 5, usage.TotalTokens)
|
||||
|
||||
got := recorder.Body.String()
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
require.Contains(t, got, `"role":"assistant"`)
|
||||
require.Contains(t, got, `"content":"hello"`)
|
||||
require.Contains(t, got, `"name":"lookup"`)
|
||||
require.Contains(t, got, `"arguments":"{\"q\":\"x\"}"`)
|
||||
require.Contains(t, got, `"finish_reason":"tool_calls"`)
|
||||
require.Contains(t, got, `"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5`)
|
||||
require.Contains(t, got, `data: [DONE]`)
|
||||
requireOrderedSubstrings(t, got,
|
||||
`"role":"assistant"`,
|
||||
`"content":"hello"`,
|
||||
`"name":"lookup"`,
|
||||
`"arguments":"{\"q\":\"x\"}"`,
|
||||
`"finish_reason":"tool_calls"`,
|
||||
`"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
}
|
||||
|
||||
func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`data: {"type":"response.output_text.delta","delta":"buffered text"}`,
|
||||
`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup"}}`,
|
||||
`data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"q\":\"x\"}"}`,
|
||||
`data: {"type":"response.done","response":{"model":"gpt-test","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newResponsesChatTestContext(t, body, false)
|
||||
|
||||
usage, err := OaiResponsesToChatBufferedStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 3, usage.TotalTokens)
|
||||
|
||||
got := recorder.Body.String()
|
||||
require.NotContains(t, got, `data:`)
|
||||
require.Contains(t, got, `"object":"chat.completion"`)
|
||||
require.Contains(t, got, `"content":"buffered text"`)
|
||||
require.Contains(t, got, `"name":"lookup"`)
|
||||
require.Contains(t, got, `"arguments":"{\"q\":\"x\"}"`)
|
||||
require.Contains(t, got, `"finish_reason":"tool_calls"`)
|
||||
}
|
||||
|
||||
func requireOrderedSubstrings(t *testing.T, s string, parts ...string) {
|
||||
t.Helper()
|
||||
|
||||
offset := 0
|
||||
for _, part := range parts {
|
||||
idx := strings.Index(s[offset:], part)
|
||||
require.NotEqualf(t, -1, idx, "missing %q after byte offset %d", part, offset)
|
||||
offset += idx + len(part)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user