Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
185 lines
6.3 KiB
Go
185 lines
6.3 KiB
Go
package openai
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"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/relayconvert"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
|
defer service.CloseResponseBodyGracefully(resp)
|
|
if strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
|
|
body, err := readBufferedResponsesSSE(resp.Body)
|
|
if err != nil {
|
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
|
|
}
|
|
converted := *resp
|
|
converted.Header = resp.Header.Clone()
|
|
converted.Header.Set("Content-Type", "application/json")
|
|
converted.Header.Del("Content-Length")
|
|
converted.Body = io.NopCloser(bytes.NewReader(body))
|
|
converted.ContentLength = int64(len(body))
|
|
return OaiResponsesHandler(c, info, &converted)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
info.ObserveResponseModel(responsesResponse.Model)
|
|
responseBody = rewriteSGLangResponsesCreatedAt(info, responseBody, "created_at", responsesResponse.CreatedAt)
|
|
|
|
// 写入新的 response body
|
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
|
|
|
// compute usage
|
|
usage := &dto.Usage{}
|
|
service.ApplyResponsesUsage(usage, responsesResponse.Usage)
|
|
// 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
|
|
}
|
|
|
|
// Some upstreams return SSE even for stream=false. Require a terminal response
|
|
// and reuse JSON delivery and settlement so upstream usage is charged once.
|
|
func readBufferedResponsesSSE(body io.Reader) ([]byte, error) {
|
|
scanner := helper.NewStreamScanner(body)
|
|
scanner.Split(bufio.ScanLines)
|
|
accumulator := relayconvert.NewResponsesBufferedAccumulator()
|
|
var data []string
|
|
for scanner.Scan() {
|
|
line := strings.TrimSuffix(scanner.Text(), "\r")
|
|
if payload, ok := strings.CutPrefix(line, "data:"); ok {
|
|
data = append(data, strings.TrimPrefix(payload, " "))
|
|
}
|
|
if line != "" || len(data) == 0 {
|
|
continue
|
|
}
|
|
payload := strings.Join(data, "\n")
|
|
data = nil
|
|
if payload == "[DONE]" {
|
|
break
|
|
}
|
|
var event dto.ResponsesStreamResponse
|
|
if err := common.UnmarshalJsonStr(payload, &event); err != nil {
|
|
return nil, err
|
|
}
|
|
accumulator.ProcessEvent(&event)
|
|
switch event.Type {
|
|
case "error", "response.error", "response.failed", "response.cancelled", "response.canceled":
|
|
return nil, fmt.Errorf("responses stream ended with %s", event.Type)
|
|
case "response.completed", "response.done", "response.incomplete":
|
|
if event.Response == nil {
|
|
return nil, fmt.Errorf("responses terminal event has no response")
|
|
}
|
|
result := []byte(gjson.Get(payload, "response").Raw)
|
|
if len(event.Response.Output) == 0 {
|
|
accumulator.SupplementResponseOutput(event.Response)
|
|
if len(event.Response.Output) != 0 {
|
|
output, err := common.Marshal(event.Response.Output)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sjson.SetRawBytes(result, "output", output)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("responses stream ended without a terminal response")
|
|
}
|
|
|
|
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)
|
|
|
|
accumulator := service.NewResponsesUsageAccumulator(info)
|
|
|
|
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
|
|
}
|
|
if streamResponse.Response != nil {
|
|
data = string(rewriteSGLangResponsesCreatedAt(info, []byte(data), "response.created_at", streamResponse.Response.CreatedAt))
|
|
}
|
|
sendResponsesStreamData(c, streamResponse, data)
|
|
accumulator.Observe(&streamResponse)
|
|
})
|
|
|
|
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, info.StreamStatus)
|
|
info.StreamStatus.RequireTerminal()
|
|
return accumulator.Finish(), nil
|
|
}
|
|
|
|
func rewriteSGLangResponsesCreatedAt(info *relaycommon.RelayInfo, payload []byte, path string, createdAt dto.IntValue) []byte {
|
|
if info.GetChannelType() != constant.ChannelTypeSGLang {
|
|
return payload
|
|
}
|
|
if !gjson.GetBytes(payload, path).Exists() {
|
|
return payload
|
|
}
|
|
patched, err := sjson.SetBytes(payload, path, int(createdAt))
|
|
if err != nil {
|
|
return payload
|
|
}
|
|
return patched
|
|
}
|