feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+3
View File
@@ -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 {
+3 -4
View File
@@ -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
+78 -18
View File
@@ -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()
+45 -11
View File
@@ -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)
}
+8 -6
View File
@@ -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
}
}
+1 -1
View File
@@ -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)
}
+7 -7
View File
@@ -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():
}
+6 -8
View File
@@ -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)
})
}
}
+51 -12
View File
@@ -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)
}
+29
View File
@@ -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,
}
}