feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -97,6 +97,9 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
if info.ChannelType == constant.ChannelTypeAzure && info.RelayMode == relayconstant.RelayModeResponses {
|
||||
info.VendorToolUsage = azureResponsesToolUsage
|
||||
}
|
||||
|
||||
// initialize ThinkingContentInfo when thinking_to_content is enabled
|
||||
if info.ChannelSetting.ThinkingToContent {
|
||||
|
||||
@@ -2,7 +2,6 @@ package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -59,7 +58,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
// 读取响应体到缓冲区
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("failed to read TTS response body: %v", err))
|
||||
logger.LogError(c, common.LogText("failed to read TTS response body: %v", err))
|
||||
c.Writer.WriteHeaderNow()
|
||||
return usage
|
||||
}
|
||||
@@ -68,7 +67,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
c.Writer.WriteHeaderNow()
|
||||
_, err = c.Writer.Write(bodyBytes)
|
||||
if err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("failed to write TTS response: %v", err))
|
||||
logger.LogError(c, common.LogText("failed to write TTS response: %v", err))
|
||||
}
|
||||
|
||||
// 计算音频时长并更新 usage
|
||||
@@ -96,7 +95,7 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||
|
||||
if durationErr != nil {
|
||||
logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr))
|
||||
logger.LogWarn(c, common.LogText("failed to get audio duration: %v", durationErr))
|
||||
// 如果无法获取时长,则设置保底的 CompletionTokens,根据body大小计算
|
||||
sizeInKB := float64(len(bodyBytes)) / 1000.0
|
||||
estimatedTokens := int(math.Ceil(sizeInKB)) // 粗略估算每KB约等于1 token
|
||||
|
||||
@@ -85,7 +85,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
||||
|
||||
var streamResp dto.ResponsesStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
|
||||
logger.LogError(c, "failed to unmarshal buffered responses stream event: "+err.Error())
|
||||
logger.LogError(c, common.LogText("failed to unmarshal buffered responses stream event: %s", err.Error()))
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
break
|
||||
}
|
||||
@@ -187,11 +187,27 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
streamErr := (*types.NewAPIError)(nil)
|
||||
// upstreamFailed records a response.failed / response.error event and
|
||||
// failedUsage the usage it reported, if any.
|
||||
upstreamFailed := false
|
||||
var failedUsage *dto.Usage
|
||||
|
||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
|
||||
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
|
||||
}
|
||||
|
||||
// writeFailed stops the stream after a failed client write. When the client
|
||||
// went away the upstream already produced output, so the request settles
|
||||
// normally instead of failing; any other write error stays a server error.
|
||||
writeFailed := func(err error) bool {
|
||||
if c.Request.Context().Err() != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
return false
|
||||
}
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
|
||||
sendGeminiResponse := func(geminiResponse *dto.GeminiChatResponse) bool {
|
||||
if geminiResponse == nil {
|
||||
return true
|
||||
@@ -213,8 +229,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return true
|
||||
}
|
||||
if err := helper.ObjectData(c, &value); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
return writeFailed(err)
|
||||
}
|
||||
return true
|
||||
case *dto.ChatCompletionsStreamResponse:
|
||||
@@ -222,14 +237,12 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return true
|
||||
}
|
||||
if err := helper.ObjectData(c, value); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
return writeFailed(err)
|
||||
}
|
||||
return true
|
||||
case dto.ClaudeResponse:
|
||||
if err := helper.ClaudeData(c, value); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
return writeFailed(err)
|
||||
}
|
||||
return true
|
||||
case *dto.ClaudeResponse:
|
||||
@@ -237,8 +250,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return true
|
||||
}
|
||||
if err := helper.ClaudeData(c, *value); err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
return writeFailed(err)
|
||||
}
|
||||
return true
|
||||
case dto.GeminiChatResponse:
|
||||
@@ -253,13 +265,13 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
if streamErr != nil {
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, 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())
|
||||
logger.LogError(c, common.LogText("failed to unmarshal responses stream event: %s", err.Error()))
|
||||
sr.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -268,34 +280,72 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
info.ObserveResponseModel(streamResp.Response.Model)
|
||||
}
|
||||
if streamResp.Type == "response.error" || streamResp.Type == "response.failed" {
|
||||
upstreamFailed = true
|
||||
if streamResp.Response != nil {
|
||||
failedUsage = streamResp.Response.Usage
|
||||
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
|
||||
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
|
||||
if err != nil {
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
for _, result := range results {
|
||||
if !sendStreamResult(result) {
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
if !upstreamFailed || state.UsageText() == "" {
|
||||
return nil, streamErr
|
||||
}
|
||||
// The upstream failed after output reached the client: bill the
|
||||
// delivered output (upstream usage when reported, otherwise the
|
||||
// estimate) and send the client the same error the failed request
|
||||
// path writes. stream_status already records the failure.
|
||||
usage := relayconvert.UsageFromResponsesUsage(failedUsage)
|
||||
if usage.TotalTokens == 0 {
|
||||
usage = state.Usage()
|
||||
}
|
||||
if usage == nil || usage.TotalTokens == 0 {
|
||||
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
} else if usage.CompletionTokens == 0 || usage.PromptTokens == 0 {
|
||||
// As in the native Responses accumulator, reported usage that
|
||||
// omits the output or the prompt is completed from the estimate of
|
||||
// the delivered output and the prompt.
|
||||
estimate := service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = estimate.CompletionTokens
|
||||
}
|
||||
if usage.PromptTokens == 0 {
|
||||
usage.PromptTokens = estimate.PromptTokens
|
||||
}
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
if usage.BillingUsage != nil {
|
||||
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
logger.LogError(c, common.LogText("responses stream failed after output was delivered, billing the delivered output: %s", streamErr.Error()))
|
||||
streamErr.SetMessage(common.MessageWithRequestId(streamErr.Error(), c.GetString(common.RequestIdKey)))
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
c.JSON(streamErr.StatusCode, gin.H{"type": "error", "error": streamErr.ToClaudeError(common.UpstreamAddresses(c)...)})
|
||||
} else {
|
||||
c.JSON(streamErr.StatusCode, gin.H{"error": streamErr.ToOpenAIError(common.UpstreamAddresses(c)...)})
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
usage := state.Usage()
|
||||
@@ -303,6 +353,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
state.SetUsage(usage)
|
||||
}
|
||||
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
||||
info.ClaudeConvertInfo.Usage = usage
|
||||
@@ -313,12 +366,19 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
for _, result := range finalResults {
|
||||
if !sendStreamResult(result) {
|
||||
return nil, streamErr
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
}
|
||||
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
|
||||
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, info.UpstreamModelName, *usage)); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
writeFailed(err)
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -268,6 +270,59 @@ func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestOaiChatToResponsesStreamHandlerRestoresCodexCustomToolCall(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: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_exec","type":"function","function":{"name":"exec","arguments":"{\"input\":"}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"ls -la\"}"}}]},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","created":1710000000,"model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newResponsesChatTestContext(t, body, true)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
tools, marshalErr := common.Marshal([]map[string]any{
|
||||
{"type": "custom", "name": "exec", "description": "Run a shell command", "format": map[string]any{"type": "text"}},
|
||||
})
|
||||
require.NoError(t, marshalErr)
|
||||
converted, convertErr := service.ConvertRequestByID(c, info, relayconvert.ConverterOpenAIResponsesToOpenAIChat, &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: []byte(`"list files"`),
|
||||
Tools: tools,
|
||||
})
|
||||
require.NoError(t, convertErr)
|
||||
chatRequest, ok := converted.Value.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Len(t, chatRequest.Tools, 1)
|
||||
assert.Equal(t, "function", chatRequest.Tools[0].Type)
|
||||
assert.Equal(t, "exec", chatRequest.Tools[0].Function.Name)
|
||||
|
||||
_, err := OaiChatToResponsesStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
|
||||
got := recorder.Body.String()
|
||||
assert.NotContains(t, got, `event: response.function_call_arguments`)
|
||||
assert.Contains(t, got, `"delta":"ls -la"`)
|
||||
assert.Contains(t, got, `"input":"ls -la"`)
|
||||
requireOrderedSubstrings(t, got,
|
||||
`event: response.output_item.added`,
|
||||
`"type":"custom_tool_call"`,
|
||||
`event: response.custom_tool_call_input.delta`,
|
||||
`event: response.custom_tool_call_input.done`,
|
||||
`event: response.output_item.done`,
|
||||
`event: response.completed`,
|
||||
`"type":"custom_tool_call"`,
|
||||
)
|
||||
}
|
||||
|
||||
func requireOrderedSubstrings(t *testing.T, s string, parts ...string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
||||
func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error {
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil {
|
||||
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
|
||||
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResu
|
||||
}
|
||||
data, err := common.Marshal(geminiResponse)
|
||||
if err != nil {
|
||||
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
|
||||
logger.LogError(c, common.LogText("failed to marshal gemini response: %s", err.Error()))
|
||||
return err
|
||||
}
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
|
||||
@@ -206,7 +206,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
||||
case types.RelayFormatClaude:
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
|
||||
common.SysLog("error unmarshalling stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,12 +214,12 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
||||
|
||||
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
|
||||
if err != nil {
|
||||
common.SysLog("error converting Claude stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error converting Claude stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
claudeResponses, ok := result.Value.([]*dto.ClaudeResponse)
|
||||
if !ok {
|
||||
common.SysLog(fmt.Sprintf("expected Claude stream responses, got %T", result.Value))
|
||||
common.SysLog(common.LogText("expected Claude stream responses, got %T", result.Value))
|
||||
return
|
||||
}
|
||||
for _, resp := range claudeResponses {
|
||||
@@ -230,34 +230,34 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
||||
case types.RelayFormatGemini:
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
|
||||
common.SysLog("error unmarshalling stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error unmarshalling stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
state, err := chatToGeminiStreamState(info, &streamResponse)
|
||||
if err != nil {
|
||||
common.SysLog("error creating Gemini stream state: " + err.Error())
|
||||
common.SysLog(common.LogText("error creating Gemini stream state: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
state.SetUsage(usage)
|
||||
|
||||
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
|
||||
if err != nil {
|
||||
common.SysLog("error converting final Gemini stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error converting final Gemini stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := sendGeminiStreamResults(c, results); err != nil {
|
||||
common.SysLog("error sending final Gemini stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error sending final Gemini stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
results, err = service.FinalizeStreamResponse(c, info, state)
|
||||
if err != nil {
|
||||
common.SysLog("error finalizing Gemini stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error finalizing Gemini stream response: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := sendGeminiStreamResults(c, results); err != nil {
|
||||
common.SysLog("error sending finalized Gemini stream response: " + err.Error())
|
||||
common.SysLog(common.LogText("error sending finalized Gemini stream response: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,5 +266,39 @@ func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamR
|
||||
if data == "" {
|
||||
return
|
||||
}
|
||||
if streamResponse.Type == "error" || streamResponse.Type == "response.error" || streamResponse.Type == "response.failed" {
|
||||
// Stream events bypass the controller's HTTP error projection. Keep
|
||||
// diagnostics server-side and emit only protocol fields to the client.
|
||||
var original map[string]any
|
||||
if common.UnmarshalJsonStr(data, &original) != nil {
|
||||
return
|
||||
}
|
||||
diagnostic := map[string]any{"type": streamResponse.Type, "code": original["code"], "message": original["message"], "error": original["error"]}
|
||||
if response, ok := original["response"].(map[string]any); ok {
|
||||
diagnostic["response_error"] = response["error"]
|
||||
}
|
||||
detail, _ := common.Marshal(diagnostic)
|
||||
logger.LogError(c, "upstream Responses stream failure: "+string(detail))
|
||||
message := common.MessageWithRequestId("The model service is temporarily unavailable. Please try again later.", c.GetString(common.RequestIdKey))
|
||||
publicError := map[string]any{"type": "api_error", "code": "model_service_error", "message": message}
|
||||
safe := map[string]any{"type": streamResponse.Type, "code": "model_service_error", "message": message, "error": publicError}
|
||||
if sequence, ok := original["sequence_number"]; ok {
|
||||
safe["sequence_number"] = sequence
|
||||
}
|
||||
if response, ok := original["response"].(map[string]any); ok {
|
||||
publicResponse := map[string]any{"status": "failed", "error": publicError}
|
||||
for _, field := range []string{"id", "object", "created_at", "model", "usage"} {
|
||||
if value, exists := response[field]; exists {
|
||||
publicResponse[field] = value
|
||||
}
|
||||
}
|
||||
safe["response"] = publicResponse
|
||||
}
|
||||
encoded, err := common.Marshal(safe)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = string(encoded)
|
||||
}
|
||||
_ = helper.ResponseChunkData(c, streamResponse, data)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/tokenkit"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -102,7 +103,7 @@ func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, fo
|
||||
|
||||
func OaiStreamHandler(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")
|
||||
logger.LogError(c, common.LogText("invalid response or response body"))
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
if lastStreamData != "" {
|
||||
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
|
||||
common.SysLog("error handling stream format: " + err.Error())
|
||||
common.SysLog(common.LogText("error handling stream format: %s", err.Error()))
|
||||
sr.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +137,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
lastStreamData = data
|
||||
observeStreamChoices(info, data, seenStreamToolCalls, &streamFunctionCallNames)
|
||||
if err := processTokenData(info, data, &responseTextBuilder, &toolCount); err != nil {
|
||||
logger.LogError(c, "error processing stream token data: "+err.Error())
|
||||
logger.LogError(c, common.LogText("error processing stream token data: %s", err.Error()))
|
||||
sr.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +149,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
shouldSendLastResp := true
|
||||
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
|
||||
&containStreamUsage, info, &shouldSendLastResp); err != nil {
|
||||
logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
|
||||
logger.LogError(c, common.LogText("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
|
||||
}
|
||||
|
||||
// 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
|
||||
@@ -268,7 +269,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
if enterpriseResponse.Success {
|
||||
responseBody = enterpriseResponse.Data
|
||||
} else {
|
||||
logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
|
||||
logger.LogError(c, common.LogText("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +297,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
info.CountBillableToolCall(dto.BuildInCallFunctionCall, tc.Function.Name)
|
||||
}
|
||||
}
|
||||
info.ApplyVendorToolUsage(responseBody)
|
||||
|
||||
forceFormat := false
|
||||
if info.ChannelSetting.ForceFormat {
|
||||
@@ -307,7 +309,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
completionTokens := simpleResponse.Usage.CompletionTokens
|
||||
if completionTokens == 0 {
|
||||
for _, choice := range simpleResponse.Choices {
|
||||
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
|
||||
ctkm := tokenkit.Count(info.UpstreamModelName, choice.Message.StringContent()+choice.Message.GetReasoningContent())
|
||||
completionTokens += ctkm
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
|
||||
|
||||
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
logger.LogError(c, "invalid image stream response")
|
||||
logger.LogError(c, common.LogText("invalid image stream response"))
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
@@ -148,7 +148,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
info.IsFirstRequest = false
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
@@ -163,9 +163,9 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
// print now usage
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, common.LogText("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, common.LogText("realtime streaming localUsage: %v", localUsage))
|
||||
|
||||
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
|
||||
realtimeSession := realtimeEvent.Session
|
||||
@@ -180,7 +180,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
logger.LogInfo(c, common.LogText("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.OutputTokens += textToken + audioToken
|
||||
localUsage.OutputTokenDetails.TextTokens += textToken
|
||||
@@ -206,7 +206,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
|
||||
case <-targetClosed:
|
||||
case err := <-errChan:
|
||||
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
|
||||
logger.LogError(c, "realtime error: "+err.Error())
|
||||
logger.LogError(c, common.LogText("realtime error: %s", err.Error()))
|
||||
case <-c.Done():
|
||||
}
|
||||
|
||||
|
||||
@@ -73,13 +73,11 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
info.CountBillableToolCall(dto.BuildInCallFunctionCall, output.Name)
|
||||
}
|
||||
}
|
||||
info.ApplyVendorToolUsage(responseBody)
|
||||
|
||||
imageCounter := &relaycommon.ImageGenerationCallCounter{}
|
||||
if !relaycommon.IsNonBillableResponsesStatus(responsesResponse.Status) {
|
||||
for i := range responsesResponse.Output {
|
||||
idx := i
|
||||
imageCounter.Observe(&responsesResponse.Output[i], &idx)
|
||||
}
|
||||
for i := range responsesResponse.Output {
|
||||
imageCounter.Observe(&responsesResponse.Output[i], &i)
|
||||
}
|
||||
imageCounter.Commit(info)
|
||||
|
||||
@@ -140,7 +138,7 @@ func readBufferedResponsesSSE(body io.Reader) ([]byte, error) {
|
||||
|
||||
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")
|
||||
logger.LogError(c, common.LogText("invalid response or response body"))
|
||||
return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse)
|
||||
}
|
||||
|
||||
@@ -153,7 +151,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
// 检查当前数据是否包含 completed 状态和 usage 信息
|
||||
var streamResponse dto.ResponsesStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
||||
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
|
||||
logger.LogError(c, common.LogText("failed to unmarshal stream response: %s", err.Error()))
|
||||
sr.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -161,7 +159,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
data = string(rewriteSGLangResponsesCreatedAt(info, []byte(data), "response.created_at", streamResponse.Response.CreatedAt))
|
||||
}
|
||||
sendResponsesStreamData(c, streamResponse, data)
|
||||
accumulator.Observe(&streamResponse)
|
||||
accumulator.Observe(&streamResponse, common.StringToByteSlice(data))
|
||||
})
|
||||
|
||||
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, info.StreamStatus)
|
||||
|
||||
@@ -5,13 +5,16 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -193,7 +196,7 @@ func TestOaiResponsesHandlerCountsCompletedImageGenerationOutputs(t *testing.T)
|
||||
assert.False(t, c.GetBool("image_generation_call"))
|
||||
}
|
||||
|
||||
func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testing.T) {
|
||||
func TestOaiResponsesHandlerCountsImageGenerationOnIncompleteStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body, err := common.Marshal(dto.OpenAIResponsesResponse{
|
||||
@@ -229,7 +232,7 @@ func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testin
|
||||
|
||||
_, apiErr := OaiResponsesHandler(c, info, resp)
|
||||
require.Nil(t, apiErr)
|
||||
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
}
|
||||
|
||||
func runResponsesImageBillingStream(t *testing.T, events ...string) *relaycommon.RelayInfo {
|
||||
@@ -284,14 +287,14 @@ func TestOaiResponsesStreamHandlerDeduplicatesCompletedImageOutput(t *testing.T)
|
||||
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
}
|
||||
|
||||
func TestOaiResponsesStreamHandlerDiscardsImageOutputOnIncomplete(t *testing.T) {
|
||||
func TestOaiResponsesStreamHandlerCountsImageOutputOnIncomplete(t *testing.T) {
|
||||
info := runResponsesImageBillingStream(
|
||||
t,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_1","status":"completed","result":"base64-a"}}`,
|
||||
`{"type":"response.incomplete","response":{"status":"incomplete"}}`,
|
||||
)
|
||||
|
||||
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
}
|
||||
|
||||
func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) {
|
||||
@@ -392,3 +395,139 @@ func TestOaiResponsesStreamHandlerKeepsNonSGLangCreatedAt(t *testing.T) {
|
||||
require.Nil(t, apiErr)
|
||||
assert.Contains(t, w.Body.String(), `1786588600.0`)
|
||||
}
|
||||
|
||||
func TestResponsesStreamFailureHidesRoutingDetails(t *testing.T) {
|
||||
for _, eventType := range []string{"error", "response.error", "response.failed"} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
secret := "智能路由流式响应中途失败。原因:个人池商家在流式响应开始后中断。 request_id: upstream-private-id"
|
||||
original := map[string]any{"type": eventType, "sequence_number": 3, "code": "vendor_stream_error", "message": secret, "debug": secret, "error": map[string]any{"message": secret}, "response": map[string]any{"id": "resp_test", "status": "failed", "error": map[string]any{"type": "vendor_error", "code": "vendor_stream_error", "message": secret}, "usage": map[string]any{"input_tokens": 10, "output_tokens": 2}, "debug": secret}}
|
||||
encoded, err := common.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
var event dto.ResponsesStreamResponse
|
||||
require.NoError(t, common.Unmarshal(encoded, &event))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "local-request-id")
|
||||
sendResponsesStreamData(c, event, string(encoded))
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "event: "+eventType)
|
||||
assert.Contains(t, body, "model_service_error")
|
||||
assert.Contains(t, body, "local-request-id")
|
||||
assert.Contains(t, body, `"status":"failed"`)
|
||||
assert.Contains(t, body, `"input_tokens":10`)
|
||||
for _, detail := range []string{"个人池", "智能路由", "upstream-private-id", "vendor_stream_error", "debug"} {
|
||||
assert.NotContains(t, body, detail)
|
||||
}
|
||||
// Projection must not change the event used for outcome and billing.
|
||||
require.NotNil(t, event.Response)
|
||||
assert.Equal(t, "vendor_stream_error", event.Response.GetOpenAIError().Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesSuccessfulStreamDataUnchanged(t *testing.T) {
|
||||
body := `{"type":"response.output_text.delta","delta":"个人池 is user content"}`
|
||||
var event dto.ResponsesStreamResponse
|
||||
require.NoError(t, common.UnmarshalJsonStr(body, &event))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
sendResponsesStreamData(c, event, body)
|
||||
assert.Contains(t, w.Body.String(), body)
|
||||
}
|
||||
|
||||
// A converted hosted web search reaches Azure and Xinference Chat Completions
|
||||
// as web_search_options, like OpenAI; the upstream decides whether it applies.
|
||||
// Azure Responses bills web search by tool_usage.web_search.num_requests (Bing
|
||||
// transactions) under bing_web_search, and the explicit zeros silence the
|
||||
// web_search_call items, which leaves one surcharge row.
|
||||
func TestAzureHostedWebSearch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
t.Run("Claude to Chat", func(t *testing.T) {
|
||||
const body = `{"model":"gpt-5.1","max_tokens":64,"messages":[{"role":"user","content":"Weather?"}],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`
|
||||
for _, channelType := range []int{constant.ChannelTypeAzure, constant.ChannelTypeXinference, constant.ChannelTypeOpenAI} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channelType, UpstreamModelName: "gpt-5.1"},
|
||||
}
|
||||
adaptor := &Adaptor{}
|
||||
adaptor.Init(info)
|
||||
var request dto.ClaudeRequest
|
||||
require.NoError(t, common.UnmarshalJsonStr(body, &request))
|
||||
converted, err := adaptor.ConvertClaudeRequest(c, info, &request)
|
||||
require.NoError(t, err)
|
||||
wire, err := common.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.Contains(string(wire), `"web_search_options"`), channelType)
|
||||
var codes []string
|
||||
for _, diagnostic := range info.ConversionDiagnostics() {
|
||||
codes = append(codes, diagnostic.Code)
|
||||
}
|
||||
assert.False(t, slices.Contains(codes, "hosted_web_search_omitted"), channelType)
|
||||
}
|
||||
})
|
||||
|
||||
const searchCall = `{"type":"web_search_call","status":"completed","action":{"type":"search","query":"go"}}`
|
||||
const usage = `"usage":{"input_tokens":10,"output_tokens":3,"total_tokens":13},"tool_usage":{"web_search":{"num_requests":5}}`
|
||||
output := strings.Repeat(searchCall+",", 3) + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Go 1.25"}]}`
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "json",
|
||||
contentType: "application/json",
|
||||
body: `{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + `}`,
|
||||
},
|
||||
{
|
||||
name: "stream",
|
||||
contentType: "text/event-stream",
|
||||
body: strings.Repeat(`data: {"type":"response.output_item.done","output_index":0,"item":`+searchCall+"}\n\n", 3) +
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.1","status":"completed","output":[` + output + `],` + usage + "}}\n\ndata: [DONE]\n\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run("Responses "+tt.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeResponses,
|
||||
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||
OriginModelName: "gpt-5.1",
|
||||
IsStream: tt.name == "stream",
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeAzure, UpstreamModelName: "gpt-5.1"},
|
||||
}
|
||||
adaptor := &Adaptor{}
|
||||
adaptor.Init(info)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(tt.body)),
|
||||
Header: http.Header{"Content-Type": []string{tt.contentType}},
|
||||
}
|
||||
var apiErr *types.NewAPIError
|
||||
if info.IsStream {
|
||||
_, apiErr = OaiResponsesStreamHandler(c, info, resp)
|
||||
} else {
|
||||
_, apiErr = OaiResponsesHandler(c, info, resp)
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
billed := map[string]int{}
|
||||
for name, tool := range info.ResponsesUsageInfo.BuiltInTools {
|
||||
if tool.CallCount > 0 {
|
||||
billed[name] = tool.CallCount
|
||||
}
|
||||
}
|
||||
assert.Equal(t, map[string]int{"bing_web_search": 5}, billed)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
streamErr := (*types.NewAPIError)(nil)
|
||||
// upstreamFailure is the upstream or conversion failure the client was
|
||||
// told about with response.failed.
|
||||
var upstreamFailure *types.NewAPIError
|
||||
|
||||
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
|
||||
data, err := common.Marshal(event.Payload)
|
||||
@@ -87,6 +90,13 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return false
|
||||
}
|
||||
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
|
||||
// When the client went away the upstream already produced output,
|
||||
// so the stream stops and settles normally instead of failing; any
|
||||
// other write error stays a server error.
|
||||
if c.Request.Context().Err() != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
return false
|
||||
}
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
@@ -112,7 +122,7 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
if streamErr != nil {
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,24 +130,28 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
|
||||
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
|
||||
sr.Stop(streamErr)
|
||||
// The client got response.failed, so the request is never
|
||||
// retried; the recorded error keeps the stream failed.
|
||||
upstreamFailure = types.WithOpenAIError(*oaiError, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
|
||||
sr.Stop(upstreamFailure)
|
||||
return
|
||||
}
|
||||
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var chunk dto.ChatCompletionsStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
|
||||
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
|
||||
logger.LogError(c, common.LogText("failed to unmarshal chat stream response: %s", err.Error()))
|
||||
if failResponsesStream(err) {
|
||||
sr.Stop(streamErr)
|
||||
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
|
||||
sr.Stop(upstreamFailure)
|
||||
return
|
||||
}
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -145,22 +159,23 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
|
||||
if err != nil {
|
||||
if failResponsesStream(err) {
|
||||
sr.Stop(streamErr)
|
||||
upstreamFailure = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError, types.ErrOptionWithSkipRetry())
|
||||
sr.Stop(upstreamFailure)
|
||||
return
|
||||
}
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
for _, result := range results {
|
||||
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||
if !ok {
|
||||
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
if !sendEvent(event) {
|
||||
sr.Stop(streamErr)
|
||||
stopStream(sr, streamErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -171,10 +186,19 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
}
|
||||
|
||||
usage := state.Usage()
|
||||
if usage == nil || usage.TotalTokens == 0 {
|
||||
noUpstreamUsage := usage == nil || usage.TotalTokens == 0
|
||||
if upstreamFailure != nil && noUpstreamUsage && state.UsageText() == "" {
|
||||
// The stream failed before any output reached the client, which
|
||||
// already received response.failed: the request is not charged.
|
||||
return nil, upstreamFailure
|
||||
}
|
||||
if noUpstreamUsage {
|
||||
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
state.SetUsage(usage)
|
||||
}
|
||||
if info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
finalResults, err := service.FinalizeStreamResponse(c, info, state)
|
||||
if err != nil {
|
||||
@@ -189,9 +213,24 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
if !sendEvent(event) {
|
||||
return nil, streamErr
|
||||
if streamErr != nil {
|
||||
return nil, streamErr
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// stopStream ends a converted stream from the scanner callback. A nil
|
||||
// streamErr (the stream stops because the client went away) must reach
|
||||
// StreamResult.Stop as an untyped nil; a typed nil *NewAPIError is a non-nil
|
||||
// error that StreamResult would record as a failure and dereference.
|
||||
func stopStream(sr *helper.StreamResult, streamErr *types.NewAPIError) {
|
||||
if streamErr == nil {
|
||||
sr.Stop(nil)
|
||||
return
|
||||
}
|
||||
sr.Stop(streamErr)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// azureResponsesToolUsage bills Azure Responses web search by the Bing request
|
||||
// count the service reports (tool_usage.web_search.num_requests, $14 per 1,000
|
||||
// transactions) under its own key, and silences the web_search_call items:
|
||||
// Azure documents "Use tool_usage.web_search.num_requests" rather than
|
||||
// counting web_search_call. gjson truncates fractional values and reads
|
||||
// non-numbers as zero; SetBillableToolCount ignores negatives and settlement
|
||||
// applies MaxBillableToolCallCount.
|
||||
func azureResponsesToolUsage(raw []byte) map[string]int {
|
||||
count := gjson.GetBytes(raw, "tool_usage.web_search.num_requests")
|
||||
if !count.Exists() {
|
||||
count = gjson.GetBytes(raw, "response.tool_usage.web_search.num_requests")
|
||||
}
|
||||
if !count.Exists() {
|
||||
return nil
|
||||
}
|
||||
return map[string]int{
|
||||
"bing_web_search": int(count.Int()),
|
||||
dto.BuildInToolWebSearch: 0,
|
||||
dto.BuildInToolWebSearchPreview: 0,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user