mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-08-31 02:41:34 +00:00
Responses SSE has no "data: [DONE]" sentinel, so OaiResponsesStreamHandler never signaled completion to the stream scanner: the end reason was decided by a race between upstream EOF and the client closing its socket right after the final event. Clients like codex CLI close immediately after response.completed, and upstreams that linger before EOF lose that race every time, mislabeling finished streams as client_gone (#6649). Calling sr.Done() on response.completed / response.done records the terminal reason at the moment it is known and stops the scanner without waiting for EOF. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
6.6 KiB
Go
185 lines
6.6 KiB
Go
package openai
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"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/relaykit/dto"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
|
defer service.CloseResponseBodyGracefully(resp)
|
|
|
|
// read response body
|
|
var responsesResponse dto.OpenAIResponsesResponse
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
|
}
|
|
err = common.Unmarshal(responseBody, &responsesResponse)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
}
|
|
if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
|
}
|
|
|
|
// 写入新的 response body
|
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
|
|
|
// compute usage
|
|
usage := dto.Usage{}
|
|
if responsesResponse.Usage != nil {
|
|
usage.PromptTokens = responsesResponse.Usage.InputTokens
|
|
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
|
|
usage.TotalTokens = responsesResponse.Usage.TotalTokens
|
|
if responsesResponse.Usage.InputTokensDetails != nil {
|
|
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
|
|
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
|
|
}
|
|
}
|
|
// Count actual tool invocations from Output (not tool declarations).
|
|
for _, output := range responsesResponse.Output {
|
|
switch output.Type {
|
|
case dto.BuildInCallWebSearchCall:
|
|
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
|
|
case dto.BuildInCallFileSearchCall:
|
|
info.CountBillableToolCall(dto.BuildInCallFileSearchCall, "")
|
|
case dto.BuildInCallFunctionCall:
|
|
info.CountBillableToolCall(dto.BuildInCallFunctionCall, output.Name)
|
|
}
|
|
}
|
|
|
|
imageCounter := &relaycommon.ImageGenerationCallCounter{}
|
|
if !relaycommon.IsNonBillableResponsesStatus(responsesResponse.Status) {
|
|
for i := range responsesResponse.Output {
|
|
idx := i
|
|
imageCounter.Observe(&responsesResponse.Output[i], &idx)
|
|
}
|
|
}
|
|
imageCounter.Commit(info)
|
|
|
|
return &usage, nil
|
|
}
|
|
|
|
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
|
if resp == nil || resp.Body == nil {
|
|
logger.LogError(c, "invalid response or response body")
|
|
return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse)
|
|
}
|
|
|
|
defer service.CloseResponseBodyGracefully(resp)
|
|
|
|
var usage = &dto.Usage{}
|
|
var responseTextBuilder strings.Builder
|
|
imageCounter := &relaycommon.ImageGenerationCallCounter{}
|
|
imageCommitted := false
|
|
|
|
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
|
|
|
// 检查当前数据是否包含 completed 状态和 usage 信息
|
|
var streamResponse dto.ResponsesStreamResponse
|
|
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
|
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
|
|
sr.Error(err)
|
|
return
|
|
}
|
|
sendResponsesStreamData(c, streamResponse, data)
|
|
switch streamResponse.Type {
|
|
case "response.completed", "response.done":
|
|
if streamResponse.Response != nil {
|
|
if streamResponse.Response.Usage != nil {
|
|
if streamResponse.Response.Usage.InputTokens != 0 {
|
|
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
|
}
|
|
if streamResponse.Response.Usage.OutputTokens != 0 {
|
|
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
|
}
|
|
if streamResponse.Response.Usage.TotalTokens != 0 {
|
|
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
|
}
|
|
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
|
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
|
usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
|
|
}
|
|
}
|
|
if !imageCommitted {
|
|
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
|
|
imageCounter.Reset()
|
|
imageCounter.Commit(info)
|
|
imageCommitted = true
|
|
} else {
|
|
for i := range streamResponse.Response.Output {
|
|
idx := i
|
|
imageCounter.Observe(&streamResponse.Response.Output[i], &idx)
|
|
}
|
|
imageCounter.Commit(info)
|
|
imageCommitted = true
|
|
}
|
|
}
|
|
} else if !imageCommitted {
|
|
imageCounter.Commit(info)
|
|
imageCommitted = true
|
|
}
|
|
// Responses SSE has no "data: [DONE]" sentinel: after this event the
|
|
// upstream simply idles until it closes the socket. Mark completion here,
|
|
// otherwise the end reason is decided by a race between upstream EOF and
|
|
// the client closing its connection right after the final event, which
|
|
// mislabels finished streams as client_gone (#6649).
|
|
sr.Done()
|
|
case "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
|
if !imageCommitted {
|
|
imageCounter.Reset()
|
|
imageCounter.Commit(info)
|
|
imageCommitted = true
|
|
}
|
|
case "response.output_text.delta":
|
|
// 处理输出文本
|
|
responseTextBuilder.WriteString(streamResponse.Delta)
|
|
case dto.ResponsesOutputTypeItemDone:
|
|
if streamResponse.Item != nil {
|
|
switch streamResponse.Item.Type {
|
|
case dto.BuildInCallWebSearchCall:
|
|
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
|
|
case dto.BuildInCallFileSearchCall:
|
|
info.CountBillableToolCall(dto.BuildInCallFileSearchCall, "")
|
|
case dto.BuildInCallFunctionCall:
|
|
info.CountBillableToolCall(dto.BuildInCallFunctionCall, streamResponse.Item.Name)
|
|
case dto.ResponsesOutputTypeImageGenerationCall:
|
|
if !imageCommitted {
|
|
imageCounter.Observe(streamResponse.Item, streamResponse.OutputIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
if usage.CompletionTokens == 0 {
|
|
// 计算输出文本的 token 数量
|
|
tempStr := responseTextBuilder.String()
|
|
if len(tempStr) > 0 {
|
|
// 非正常结束,使用输出文本的 token 数量
|
|
completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName)
|
|
usage.CompletionTokens = completionTokens
|
|
}
|
|
}
|
|
|
|
if usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
|
|
usage.PromptTokens = info.GetEstimatePromptTokens()
|
|
}
|
|
|
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
|
|
|
return usage, nil
|
|
}
|