feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -51,6 +51,11 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||
}
|
||||
if info.SupportStreamOptions && info.IsStream {
|
||||
openaiRequest.StreamOptions = &dto.StreamOptions{
|
||||
IncludeUsage: true,
|
||||
}
|
||||
}
|
||||
return a.ConvertOpenAIRequest(c, info, openaiRequest)
|
||||
}
|
||||
|
||||
@@ -262,23 +267,26 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
info.SetReasoningEffort(request.ReasoningEffort)
|
||||
}
|
||||
if info.ChannelType == constant.ChannelTypeOpenRouter {
|
||||
initialIntent, err := kitreasoning.FromOpenAIChat(request)
|
||||
initialIntent, diagnostics, err := kitreasoning.FromOpenAIChat(request)
|
||||
if err != nil {
|
||||
return nil, kitreasoning.AsClientError(err)
|
||||
}
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
|
||||
var thinking dto.Thinking
|
||||
if err := common.Unmarshal(request.THINKING, &thinking); err != nil {
|
||||
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
|
||||
}
|
||||
legacyIntent, err := kitreasoning.FromClaude(&dto.ClaudeRequest{Thinking: &thinking})
|
||||
legacyIntent, diagnostics, err := kitreasoning.FromClaude(&dto.ClaudeRequest{Thinking: &thinking})
|
||||
if err != nil {
|
||||
return nil, kitreasoning.AsClientError(err)
|
||||
}
|
||||
initialIntent, err = kitreasoning.MergeExplicit(initialIntent, legacyIntent, request.Model)
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
initialIntent, diagnostics, err = kitreasoning.MergeExplicit(initialIntent, legacyIntent, request.Model)
|
||||
if err != nil {
|
||||
return nil, kitreasoning.AsClientError(err)
|
||||
}
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
request.THINKING = nil
|
||||
}
|
||||
if len(request.Usage) == 0 {
|
||||
@@ -298,7 +306,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if effort == kitreasoning.EffortNone {
|
||||
mode = kitreasoning.ModeDisabled
|
||||
}
|
||||
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(initialIntent, kitreasoning.Intent{Mode: mode, Effort: effort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
initialIntent, diagnostics, err = kitreasoning.MergeExplicitAndSuffix(initialIntent, kitreasoning.Intent{Mode: mode, Effort: effort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
return err
|
||||
}
|
||||
if !preserveSuffix {
|
||||
@@ -362,10 +372,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if preserveSuffix {
|
||||
effort = ""
|
||||
}
|
||||
currentIntent, err := kitreasoning.FromOpenAIChat(request)
|
||||
currentIntent, diagnostics, err := kitreasoning.FromOpenAIChat(request)
|
||||
if err != nil {
|
||||
return nil, kitreasoning.AsClientError(err)
|
||||
}
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
mergeSuffix := func(modelName, rawEffort string) error {
|
||||
if rawEffort == "" {
|
||||
return nil
|
||||
@@ -378,7 +389,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if suffixEffort == kitreasoning.EffortNone {
|
||||
mode = kitreasoning.ModeDisabled
|
||||
}
|
||||
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
currentIntent, diagnostics, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
return err
|
||||
}
|
||||
if err := mergeSuffix(info.UpstreamModelName, effort); err != nil {
|
||||
@@ -684,10 +697,11 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
|
||||
currentIntent, diagnostics, err := kitreasoning.FromOpenAIResponses(&request)
|
||||
if err != nil {
|
||||
return nil, kitreasoning.AsClientError(err)
|
||||
}
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
mergeSuffix := func(modelName, rawEffort string) error {
|
||||
if rawEffort == "" {
|
||||
return nil
|
||||
@@ -700,7 +714,9 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
||||
if suffixEffort == kitreasoning.EffortNone {
|
||||
mode = kitreasoning.ModeDisabled
|
||||
}
|
||||
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
currentIntent, diagnostics, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
|
||||
recordReasoningDiagnostics(c, info, diagnostics)
|
||||
return err
|
||||
}
|
||||
if err := mergeSuffix(request.Model, effort); err != nil {
|
||||
@@ -811,3 +827,15 @@ func (a *Adaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
}
|
||||
|
||||
// recordReasoningDiagnostics attaches best-effort reasoning resolutions made
|
||||
// while merging request fields with model-name effort tails to the request log.
|
||||
func recordReasoningDiagnostics(c *gin.Context, info *relaycommon.RelayInfo, diagnostics []types.ConversionDiagnostic) {
|
||||
if info == nil || len(diagnostics) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range diagnostics {
|
||||
diagnostics[i].From = info.RelayFormat
|
||||
}
|
||||
info.RecordConversionDiagnostics(c, diagnostics)
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
info.ObserveResponseModel(responsesResp.Model)
|
||||
responseValue, usage, err := convertResponsesResponseForClient(c, info, &responsesResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
@@ -60,6 +61,8 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
||||
}
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
info.StreamStatus.RequireTerminal()
|
||||
accumulator := relayconvert.NewResponsesBufferedAccumulator()
|
||||
var finalResponse *dto.OpenAIResponsesResponse
|
||||
var streamErr *types.NewAPIError
|
||||
@@ -86,6 +89,10 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
break
|
||||
}
|
||||
if streamResp.Response != nil {
|
||||
info.ObserveResponseModel(streamResp.Response.Model)
|
||||
}
|
||||
service.ObserveResponsesOutcome(info, &streamResp)
|
||||
accumulator.ProcessEvent(&streamResp)
|
||||
switch streamResp.Type {
|
||||
case "response.completed", "response.done", "response.incomplete":
|
||||
@@ -120,7 +127,7 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
||||
if finalResponse == nil {
|
||||
finalResponse = &dto.OpenAIResponsesResponse{
|
||||
ID: helper.GetResponseID(c),
|
||||
CreatedAt: int(time.Now().Unix()),
|
||||
CreatedAt: dto.IntValue(time.Now().Unix()),
|
||||
Model: info.UpstreamModelName,
|
||||
Status: []byte(`"completed"`),
|
||||
}
|
||||
@@ -257,6 +264,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return
|
||||
}
|
||||
|
||||
if streamResp.Response != nil {
|
||||
info.ObserveResponseModel(streamResp.Response.Model)
|
||||
}
|
||||
if streamResp.Type == "response.error" || streamResp.Type == "response.failed" {
|
||||
if streamResp.Response != nil {
|
||||
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
|
||||
|
||||
@@ -137,13 +137,14 @@ func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, res
|
||||
return nil
|
||||
}
|
||||
|
||||
func processTokenData(relayMode int, data string, responseTextBuilder *strings.Builder, toolCount *int) error {
|
||||
switch relayMode {
|
||||
func processTokenData(info *relaycommon.RelayInfo, data string, responseTextBuilder *strings.Builder, toolCount *int) error {
|
||||
switch info.RelayMode {
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
info.ObserveResponseModel(streamResponse.Model)
|
||||
return ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount)
|
||||
case relayconstant.RelayModeCompletions:
|
||||
var streamResponse dto.CompletionsStreamResponse
|
||||
|
||||
@@ -2,6 +2,7 @@ package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,9 +11,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
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/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -35,6 +39,112 @@ func newImageTestContext(t *testing.T, body, contentType string, isStream bool)
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
func TestImageExpressionUsesCompletedCountAndProtectsAbortedStreams(t *testing.T) {
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
stream, abort bool
|
||||
requested, want int
|
||||
}{
|
||||
{"JSON uses actual count", `{"data":[{"b64_json":"first"},{"b64_json":"second"}]}`, false, false, 3, 2},
|
||||
{"JSON wrapped as SSE uses actual count", `{"data":[{"b64_json":"first"}]}`, true, false, 3, 1},
|
||||
{"empty response retains request", `{"data":[]}`, false, false, 3, 3},
|
||||
{"JSON object data counts one image", `{"data":{"url":"https://example.com/a.png","b64_json":"first"}}`, false, false, 3, 1},
|
||||
{"JSON wrapped as SSE counts object data once", `{"data":{"b64_json":"first"}}`, true, false, 3, 1},
|
||||
{"completed stream refunds missing images", "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"first\"}\n\ndata: [DONE]\n\n", true, false, 3, 1},
|
||||
{"client abort cannot reduce count", "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"first\"}\n\n", true, true, 3, 3},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
contentType := "application/json"
|
||||
if strings.HasPrefix(tc.body, "data:") {
|
||||
contentType = "text/event-stream"
|
||||
}
|
||||
c, _, resp, info := newImageTestContext(t, tc.body, contentType, tc.stream)
|
||||
if tc.abort {
|
||||
c, _, resp, info = newDisconnectingImageStream(t, tc.body, "first")
|
||||
}
|
||||
info.TieredBillingSnapshot = &billingexpr.BillingSnapshot{EstimatedImageCount: &tc.requested}
|
||||
if tc.stream {
|
||||
_, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
} else {
|
||||
_, err := OpenaiImageHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
count := info.RequestedImageCount()
|
||||
if info.BillingImageCount != nil {
|
||||
count = *info.BillingImageCount
|
||||
}
|
||||
assert.Equal(t, tc.want, count)
|
||||
assert.Empty(t, info.PriceData.OtherRatios(), "expression quantities must not add a legacy multiplier")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCacheUsageAcrossResponseFormats(t *testing.T) {
|
||||
previousTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = previousTimeout })
|
||||
// Contract fixture for compatible upstreams. Live OpenAI cache response
|
||||
// verification is a separate release check; this is not a captured response.
|
||||
const usageJSON = `{"input_tokens":1000,"output_tokens":100,"total_tokens":1100,"input_tokens_details":{"text_tokens":400,"image_tokens":600,"cached_tokens":300,"cached_tokens_details":{"text_tokens":100,"image_tokens":200}}}`
|
||||
for _, mode := range []string{"image_generation", "image_edit"} {
|
||||
for _, stream := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/stream=%t", mode, stream), func(t *testing.T) {
|
||||
body := `{"data":[{"b64_json":"image"}],"usage":` + usageJSON + `}`
|
||||
contentType := "application/json"
|
||||
if stream {
|
||||
body = "data: {\"type\":\"" + mode + ".completed\",\"usage\":" + usageJSON + "}\n\ndata: [DONE]\n\n"
|
||||
contentType = "text/event-stream"
|
||||
}
|
||||
ctx, recorder, response, info := newImageTestContext(t, body, contentType, stream)
|
||||
info.RelayMode = relayconstant.RelayModeImagesGenerations
|
||||
if mode == "image_edit" {
|
||||
info.RelayMode = relayconstant.RelayModeImagesEdits
|
||||
ctx.Request.URL.Path = "/v1/images/edits"
|
||||
}
|
||||
result, apiErr := (&Adaptor{}).DoResponse(ctx, response, info)
|
||||
require.Nil(t, apiErr)
|
||||
usage := result.(*dto.Usage)
|
||||
assert.Equal(t, 1000, usage.PromptTokens)
|
||||
assert.Equal(t, 300, usage.PromptTokensDetails.CachedTokens)
|
||||
require.NotNil(t, usage.PromptTokensDetails.CachedTokensDetails)
|
||||
assert.Equal(t, 200, *usage.PromptTokensDetails.CachedTokensDetails.ImageTokens)
|
||||
assert.Contains(t, recorder.Body.String(), `"cached_tokens_details":{"text_tokens":100,"image_tokens":200}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIUsageMapsOutputImageTokens(t *testing.T) {
|
||||
previousTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = previousTimeout })
|
||||
|
||||
const usageJSON = `{"input_tokens":15,"output_tokens":1352,"total_tokens":1367,"input_tokens_details":{"text_tokens":15,"image_tokens":0},"output_tokens_details":{"image_tokens":1120,"text_tokens":232}}`
|
||||
for _, stream := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
|
||||
body := `{"data":[{"b64_json":"image"}],"usage":` + usageJSON + `}`
|
||||
contentType := "application/json"
|
||||
if stream {
|
||||
body = "data: {\"type\":\"image_generation.completed\",\"usage\":" + usageJSON + "}\n\ndata: [DONE]\n\n"
|
||||
contentType = "text/event-stream"
|
||||
}
|
||||
ctx, _, response, info := newImageTestContext(t, body, contentType, stream)
|
||||
info.RelayMode = relayconstant.RelayModeImagesGenerations
|
||||
result, apiErr := (&Adaptor{}).DoResponse(ctx, response, info)
|
||||
require.Nil(t, apiErr)
|
||||
usage := result.(*dto.Usage)
|
||||
assert.Equal(t, 15, usage.PromptTokens)
|
||||
assert.Equal(t, 1352, usage.CompletionTokens)
|
||||
assert.Equal(t, 1120, usage.CompletionTokenDetails.ImageTokens)
|
||||
assert.Equal(t, 232, usage.CompletionTokenDetails.TextTokens)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenaiImageDoResponseUsesInfoIsStream(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -344,6 +454,24 @@ func TestOpenaiImageHandlerUsesPositiveActualCountForFixedPrice(t *testing.T) {
|
||||
usePrice: false,
|
||||
wantCount: 3,
|
||||
},
|
||||
{
|
||||
name: "object data with url and b64_json counts one image",
|
||||
body: `{"data":{"url":"https://example.com/a.png","b64_json":"` + longImage + `"}}`,
|
||||
usePrice: true,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "url and b64_json split across entries count one image",
|
||||
body: `{"data":[{"url":"https://example.com/a.png"},{"b64_json":"` + longImage + `"}]}`,
|
||||
usePrice: true,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "entries without image payload keep requested count",
|
||||
body: `{"data":[{"revised_prompt":"draw a cat"}]}`,
|
||||
usePrice: true,
|
||||
wantCount: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -361,6 +489,62 @@ func TestOpenaiImageHandlerUsesPositiveActualCountForFixedPrice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerWrapsNonStandardDataShapes covers the JSON-to-SSE
|
||||
// fallback when upstream returns data as an object, splits one image across a
|
||||
// url entry and a b64_json entry, or returns entries without any image payload.
|
||||
// Forwarded events and the billed quantity must both follow the real images.
|
||||
func TestOpenaiImageStreamHandlerWrapsNonStandardDataShapes(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantEvents int
|
||||
wantCount float64
|
||||
wantBody []string
|
||||
}{
|
||||
{
|
||||
name: "object data is forwarded as one completed event",
|
||||
body: `{"data":{"url":"https://example.com/a.png","b64_json":"first"}}`,
|
||||
wantEvents: 1,
|
||||
wantCount: 1,
|
||||
wantBody: []string{`"url":"https://example.com/a.png"`, `"b64_json":"first"`},
|
||||
},
|
||||
{
|
||||
name: "split url and b64_json entries bill one image",
|
||||
body: `{"data":[{"url":"https://example.com/a.png"},{"b64_json":"first"}]}`,
|
||||
wantEvents: 2,
|
||||
wantCount: 1,
|
||||
wantBody: []string{`"url":"https://example.com/a.png"`, `"b64_json":"first"`},
|
||||
},
|
||||
{
|
||||
name: "entries without image payload are dropped and keep requested count",
|
||||
body: `{"data":[{"revised_prompt":"draw a cat"}]}`,
|
||||
wantEvents: 0,
|
||||
wantCount: 3,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, tc.body, "application/json", true)
|
||||
info.PriceData.UsePrice = true
|
||||
info.PriceData.AddOtherRatio("n", 3)
|
||||
|
||||
_, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
out := recorder.Body.String()
|
||||
assert.Equal(t, tc.wantEvents, strings.Count(out, "event: image_generation.completed"))
|
||||
assert.Equal(t, tc.wantEvents, info.ReceivedResponseCount)
|
||||
for _, want := range tc.wantBody {
|
||||
assert.Contains(t, out, want)
|
||||
}
|
||||
assert.True(t, strings.HasSuffix(out, "data: [DONE]\n\n"))
|
||||
assert.Equal(t, tc.wantCount, info.PriceData.OtherRatios()["n"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenaiImageHandlersReturnJSONError covers JSON error responses for both
|
||||
// entry points: the non-streaming handler and the stream handler's non-SSE
|
||||
// fallback. Neither must leak the error body to the client.
|
||||
|
||||
@@ -134,14 +134,16 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
}
|
||||
|
||||
lastStreamData = data
|
||||
collectStreamFunctionCallNames(data, seenStreamToolCalls, &streamFunctionCallNames)
|
||||
if err := processTokenData(info.RelayMode, data, &responseTextBuilder, &toolCount); err != nil {
|
||||
observeStreamChoices(info, data, seenStreamToolCalls, &streamFunctionCallNames)
|
||||
if err := processTokenData(info, data, &responseTextBuilder, &toolCount); err != nil {
|
||||
logger.LogError(c, "error processing stream token data: "+err.Error())
|
||||
sr.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
info.StreamStatus.RequireTerminal()
|
||||
|
||||
// 处理最后的响应
|
||||
shouldSendLastResp := true
|
||||
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
|
||||
@@ -194,12 +196,20 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names *[]string) {
|
||||
// observeStreamChoices collects billable function call names and records the
|
||||
// finish reason facts used by health sampling from one parsed chunk.
|
||||
func observeStreamChoices(info *relaycommon.RelayInfo, data string, seen map[string]struct{}, names *[]string) {
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
||||
return
|
||||
}
|
||||
for _, choice := range streamResponse.Choices {
|
||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
||||
if *choice.FinishReason == constant.FinishReasonContentFilter {
|
||||
info.PerformanceBusinessRejection = true
|
||||
}
|
||||
info.StreamStatus.MarkCompleted()
|
||||
}
|
||||
for i, tc := range choice.Delta.ToolCalls {
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
if name == "" {
|
||||
@@ -272,8 +282,10 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
info.ObserveResponseModel(simpleResponse.Model)
|
||||
for _, choice := range simpleResponse.Choices {
|
||||
if choice.FinishReason == constant.FinishReasonContentFilter {
|
||||
info.PerformanceBusinessRejection = true
|
||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "openai_finish_reason=content_filter")
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,13 +21,6 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func updateOpenAIImageCount(info *relaycommon.RelayInfo, count int64) {
|
||||
if info == nil || !info.PriceData.UsePrice || count <= 0 || count > int64(dto.MaxImageN) {
|
||||
return
|
||||
}
|
||||
info.PriceData.AddOtherRatio("n", float64(count))
|
||||
}
|
||||
|
||||
// OpenaiImageHandler handles non-streaming OpenAI image responses
|
||||
// (generations/edits), returning the parsed usage for billing.
|
||||
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
@@ -49,7 +41,7 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
updateOpenAIImageCount(info, gjson.GetBytes(responseBody, "data.#").Int())
|
||||
info.UpdateImageCount(openaiImageResponseCount(responseBody))
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
@@ -59,14 +51,54 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
// openaiImageResponseCount counts billable images in an OpenAI-format image
|
||||
// response body. An object-shaped data is one image when it carries url or
|
||||
// b64_json. For arrays the count is the larger of the url-bearing and the
|
||||
// b64_json-bearing entry counts: a standard response uses one response_format
|
||||
// so this equals the entry count, an upstream that splits one image into a url
|
||||
// entry and a b64_json entry bills once, and entries without any image payload
|
||||
// bill nothing. A zero result leaves the requested quantity in place because
|
||||
// UpdateImageCount ignores non-positive counts.
|
||||
func openaiImageResponseCount(responseBody []byte) int64 {
|
||||
data := gjson.GetBytes(responseBody, "data")
|
||||
if data.IsObject() {
|
||||
if openaiImageDataHasField(data, "url") || openaiImageDataHasField(data, "b64_json") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if !data.IsArray() {
|
||||
return 0
|
||||
}
|
||||
var urls, b64s int64
|
||||
data.ForEach(func(_, item gjson.Result) bool {
|
||||
if openaiImageDataHasField(item, "url") {
|
||||
urls++
|
||||
}
|
||||
if openaiImageDataHasField(item, "b64_json") {
|
||||
b64s++
|
||||
}
|
||||
return true
|
||||
})
|
||||
return max(urls, b64s)
|
||||
}
|
||||
|
||||
// openaiImageDataHasField reports whether an image data entry carries a
|
||||
// non-empty string value for field.
|
||||
func openaiImageDataHasField(item gjson.Result, field string) bool {
|
||||
value := item.Get(field)
|
||||
return value.Type == gjson.String && value.Raw != `""`
|
||||
}
|
||||
|
||||
// normalizeOpenAIUsage maps the OpenAI Images usage shape (input_tokens /
|
||||
// output_tokens / input_tokens_details) onto the canonical prompt/completion
|
||||
// fields. It is used only on the OpenAI image relay paths (generations/edits,
|
||||
// streaming and non-streaming): the image API never returns prompt_tokens /
|
||||
// completion_tokens, so the overwrite (=) semantics here are equivalent to the
|
||||
// previous additive (+=) behavior while avoiding any future double-counting if
|
||||
// both field sets are ever populated. Do not reuse this on chat/embedding paths
|
||||
// without revisiting the overwrite semantics.
|
||||
// output_tokens / input_tokens_details / output_tokens_details) onto the
|
||||
// canonical prompt/completion fields. It is used only on the OpenAI image
|
||||
// relay paths (generations/edits, streaming and non-streaming): the image
|
||||
// API never returns prompt_tokens / completion_tokens, so the overwrite (=)
|
||||
// semantics here are equivalent to the previous additive (+=) behavior while
|
||||
// avoiding any future double-counting if both field sets are ever populated.
|
||||
// Do not reuse this on chat/embedding paths without revisiting the overwrite
|
||||
// semantics.
|
||||
func normalizeOpenAIUsage(usage *dto.Usage) {
|
||||
if usage == nil {
|
||||
return
|
||||
@@ -78,12 +110,10 @@ func normalizeOpenAIUsage(usage *dto.Usage) {
|
||||
usage.CompletionTokens = usage.OutputTokens
|
||||
}
|
||||
if usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.CacheWriteTokens = usage.InputTokensDetails.CacheWriteTokens
|
||||
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
|
||||
usage.PromptTokensDetails = usage.InputTokensDetails.Clone()
|
||||
}
|
||||
if usage.OutputTokensDetails != nil {
|
||||
usage.CompletionTokenDetails = *usage.OutputTokensDetails
|
||||
}
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
@@ -156,12 +186,8 @@ func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
if info.StreamStatus != nil {
|
||||
upstreamFinished := info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone ||
|
||||
info.StreamStatus.EndReason == relaycommon.StreamEndReasonEOF
|
||||
requestedN := 1.0
|
||||
if n, ok := info.PriceData.OtherRatios()["n"]; ok {
|
||||
requestedN = n
|
||||
}
|
||||
if upstreamFinished || float64(completedImages) > requestedN {
|
||||
updateOpenAIImageCount(info, completedImages)
|
||||
if upstreamFinished || completedImages > int64(info.RequestedImageCount()) {
|
||||
info.UpdateImageCount(completedImages)
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
@@ -252,8 +278,7 @@ func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
|
||||
imageCount := gjson.GetBytes(responseBody, "data.#").Int()
|
||||
updateOpenAIImageCount(info, imageCount)
|
||||
info.UpdateImageCount(openaiImageResponseCount(responseBody))
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
c.Status(http.StatusOK)
|
||||
@@ -275,8 +300,15 @@ func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
}
|
||||
}
|
||||
|
||||
for i := range imageCount {
|
||||
image := gjson.GetBytes(responseBody, "data."+strconv.FormatInt(i, 10))
|
||||
// gjson.Result.Array returns the element list for an array and a single
|
||||
// element for an object-shaped data, keeping document-relative indexes so
|
||||
// the zero-copy field forwarding below stays valid for both shapes.
|
||||
// Entries without url or b64_json carry no image and are not forwarded.
|
||||
emitted := 0
|
||||
for _, image := range gjson.GetBytes(responseBody, "data").Array() {
|
||||
if !openaiImageDataHasField(image, "url") && !openaiImageDataHasField(image, "b64_json") {
|
||||
continue
|
||||
}
|
||||
payload := []byte(`{"type":"image_generation.completed"}`)
|
||||
payload, err = sjson.SetBytes(payload, "created_at", created)
|
||||
if err != nil {
|
||||
@@ -311,6 +343,7 @@ func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
emitted++
|
||||
}
|
||||
if err := writeOpenaiImageStreamDone(c); err != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
@@ -319,7 +352,7 @@ func openaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo,
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
if info != nil {
|
||||
info.ReceivedResponseCount += int(imageCount)
|
||||
info.ReceivedResponseCount += emitted
|
||||
if info.StreamStatus == nil {
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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"
|
||||
@@ -16,10 +19,25 @@ import (
|
||||
"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
|
||||
@@ -35,11 +53,15 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
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 := relayconvert.NormalizeResponsesUsage(responsesResponse.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 {
|
||||
@@ -64,6 +86,58 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
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")
|
||||
@@ -72,10 +146,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
var usage = &dto.Usage{}
|
||||
var responseTextBuilder strings.Builder
|
||||
imageCounter := &relaycommon.ImageGenerationCallCounter{}
|
||||
imageCommitted := false
|
||||
accumulator := service.NewResponsesUsageAccumulator(info)
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
|
||||
@@ -86,77 +157,28 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
sr.Error(err)
|
||||
return
|
||||
}
|
||||
sendResponsesStreamData(c, streamResponse, data)
|
||||
switch streamResponse.Type {
|
||||
case "response.completed", "response.done":
|
||||
if streamResponse.Response != nil {
|
||||
if streamResponse.Response.Usage != nil {
|
||||
incomingUsage := relayconvert.NormalizeResponsesUsage(streamResponse.Response.Usage)
|
||||
usage = dto.MergeUsageNonZero(usage, incomingUsage)
|
||||
}
|
||||
if !imageCommitted {
|
||||
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
|
||||
imageCounter.Reset()
|
||||
imageCounter.Commit(info)
|
||||
imageCommitted = true
|
||||
} else {
|
||||
for i := range streamResponse.Response.Output {
|
||||
idx := i
|
||||
imageCounter.Observe(&streamResponse.Response.Output[i], &idx)
|
||||
}
|
||||
imageCounter.Commit(info)
|
||||
imageCommitted = true
|
||||
}
|
||||
}
|
||||
} else if !imageCommitted {
|
||||
imageCounter.Commit(info)
|
||||
imageCommitted = true
|
||||
}
|
||||
case "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
if !imageCommitted {
|
||||
imageCounter.Reset()
|
||||
imageCounter.Commit(info)
|
||||
imageCommitted = true
|
||||
}
|
||||
case "response.output_text.delta":
|
||||
// 处理输出文本
|
||||
responseTextBuilder.WriteString(streamResponse.Delta)
|
||||
case dto.ResponsesOutputTypeItemDone:
|
||||
if streamResponse.Item != nil {
|
||||
switch streamResponse.Item.Type {
|
||||
case dto.BuildInCallWebSearchCall:
|
||||
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
|
||||
case dto.BuildInCallFileSearchCall:
|
||||
info.CountBillableToolCall(dto.BuildInCallFileSearchCall, "")
|
||||
case dto.BuildInCallFunctionCall:
|
||||
info.CountBillableToolCall(dto.BuildInCallFunctionCall, streamResponse.Item.Name)
|
||||
case dto.ResponsesOutputTypeImageGenerationCall:
|
||||
if !imageCommitted {
|
||||
imageCounter.Observe(streamResponse.Item, streamResponse.OutputIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
if streamResponse.Response != nil {
|
||||
data = string(rewriteSGLangResponsesCreatedAt(info, []byte(data), "response.created_at", streamResponse.Response.CreatedAt))
|
||||
}
|
||||
sendResponsesStreamData(c, streamResponse, data)
|
||||
accumulator.Observe(&streamResponse)
|
||||
})
|
||||
|
||||
if usage.CompletionTokens == 0 {
|
||||
// 计算输出文本的 token 数量
|
||||
tempStr := responseTextBuilder.String()
|
||||
if len(tempStr) > 0 {
|
||||
// 非正常结束,使用输出文本的 token 数量
|
||||
completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName)
|
||||
usage.CompletionTokens = completionTokens
|
||||
}
|
||||
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 usage.PromptTokens == 0 && usage.CompletionTokens != 0 {
|
||||
usage.PromptTokens = info.GetEstimatePromptTokens()
|
||||
if !gjson.GetBytes(payload, path).Exists() {
|
||||
return payload
|
||||
}
|
||||
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
if usage.BillingUsage != nil {
|
||||
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
|
||||
patched, err := sjson.SetBytes(payload, path, int(createdAt))
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
return patched
|
||||
}
|
||||
@@ -18,6 +18,44 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOaiResponsesHandlerBuffersUnexpectedSSEAndPreservesUsage(t *testing.T) {
|
||||
terminal := `{"id":"resp_test","object":"response","model":"deepseek-v4.1-flash","status":"completed","extra_provider_field":true,"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}],"usage":{"input_tokens":86926,"output_tokens":163,"total_tokens":87089,"input_tokens_details":{"cached_tokens":86784}}}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantError bool
|
||||
}{
|
||||
{"completed", ": ping\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":" + terminal + "}\n\ndata: [DONE]\n\n", false},
|
||||
{"incomplete", "data: {\"type\":\"response.incomplete\",\"response\":" + strings.Replace(terminal, `"completed"`, `"incomplete"`, 1) + "}\n\n", false},
|
||||
{"truncated", "data: {\"type\":\"response.created\",\"response\":{\"model\":\"deepseek-v4.1-flash\"}}\n\ndata: [DONE]\n\n", true},
|
||||
{"failed", "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\n\n", true},
|
||||
{"invalid", "data: not-json\n\n", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
info := &relaycommon.RelayInfo{OriginModelName: "deepseek-v4.1-flash", ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
resp := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream; charset=utf-8"}}, Body: io.NopCloser(strings.NewReader(tc.body))}
|
||||
usage, apiErr := OaiResponsesHandler(c, info, resp)
|
||||
if tc.wantError {
|
||||
require.NotNil(t, apiErr)
|
||||
assert.Nil(t, usage)
|
||||
assert.False(t, c.Writer.Written())
|
||||
return
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
require.NotNil(t, usage)
|
||||
assert.Equal(t, 86926, usage.PromptTokens)
|
||||
assert.Equal(t, 163, usage.CompletionTokens)
|
||||
assert.Equal(t, 86784, usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
assert.Contains(t, w.Body.String(), `"extra_provider_field":true`)
|
||||
assert.Contains(t, w.Body.String(), `"text":"OK"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOaiResponsesHandlerCountsOutputCallsNotDeclarations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
operation_setting.SetToolPriceForTest("priced_fn", 5.0)
|
||||
@@ -265,3 +303,92 @@ func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
|
||||
}
|
||||
|
||||
func TestOaiResponsesHandlerRewritesSGLangCreatedAtToInt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeSGLang},
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_1","object":"response","created_at":1786588600.0,"status":"completed","output":[]}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}
|
||||
|
||||
_, apiErr := OaiResponsesHandler(c, info, resp)
|
||||
require.Nil(t, apiErr)
|
||||
assert.Contains(t, w.Body.String(), `"created_at":1786588600`)
|
||||
assert.NotContains(t, w.Body.String(), `1786588600.0`)
|
||||
}
|
||||
|
||||
func TestOaiResponsesStreamHandlerRewritesSGLangCreatedAtToInt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "sglang-created-at-test")
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: constant.ChannelTypeSGLang,
|
||||
UpstreamModelName: "served-model",
|
||||
},
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1.7865886E9,\"status\":\"completed\",\"output\":[]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
}
|
||||
|
||||
_, apiErr := OaiResponsesStreamHandler(c, info, resp)
|
||||
require.Nil(t, apiErr)
|
||||
assert.Contains(t, w.Body.String(), `"created_at":1786588600`)
|
||||
assert.NotContains(t, w.Body.String(), `1.7865886E9`)
|
||||
assert.NotContains(t, w.Body.String(), `1786588600.0`)
|
||||
}
|
||||
|
||||
func TestOaiResponsesStreamHandlerKeepsNonSGLangCreatedAt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldTimeout
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
c.Set(common.RequestIdKey, "openai-created-at-test")
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
DisablePing: true,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelType: constant.ChannelTypeOpenAI, UpstreamModelName: "gpt-test"},
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"created_at\":1786588600.0,\"status\":\"completed\",\"output\":[]}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
}
|
||||
|
||||
_, apiErr := OaiResponsesStreamHandler(c, info, resp)
|
||||
require.Nil(t, apiErr)
|
||||
assert.Contains(t, w.Body.String(), `1786588600.0`)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
info.ObserveResponseModel(chatResp.Model)
|
||||
if responseID := helper.GetResponseID(c); responseID != "" {
|
||||
chatResp.Id = responseID
|
||||
}
|
||||
@@ -140,6 +141,7 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
||||
return
|
||||
}
|
||||
|
||||
info.ObserveResponseModel(chunk.Model)
|
||||
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
|
||||
if err != nil {
|
||||
if failResponsesStream(err) {
|
||||
|
||||
@@ -3,11 +3,13 @@ package openai
|
||||
import (
|
||||
"testing"
|
||||
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectStreamFunctionCallNamesDedupesSameIndex(t *testing.T) {
|
||||
func TestObserveStreamChoicesDedupesFunctionCallNames(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{StreamStatus: relaycommon.NewStreamStatus()}
|
||||
seen := make(map[string]struct{})
|
||||
var names []string
|
||||
|
||||
@@ -19,9 +21,18 @@ func TestCollectStreamFunctionCallNamesDedupesSameIndex(t *testing.T) {
|
||||
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]}}]}`,
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
collectStreamFunctionCallNames(chunk, seen, &names)
|
||||
observeStreamChoices(info, chunk, seen, &names)
|
||||
}
|
||||
|
||||
require.Len(t, names, 2)
|
||||
assert.Equal(t, []string{"get_weather", "get_time"}, names)
|
||||
assert.Empty(t, info.StreamStatus.ResponseOutcome(), "tool call deltas carry no finish reason")
|
||||
|
||||
observeStreamChoices(info, `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, seen, &names)
|
||||
assert.Equal(t, "completed", info.StreamStatus.ResponseOutcome())
|
||||
assert.False(t, info.PerformanceBusinessRejection)
|
||||
|
||||
filtered := &relaycommon.RelayInfo{StreamStatus: relaycommon.NewStreamStatus()}
|
||||
observeStreamChoices(filtered, `{"choices":[{"index":0,"delta":{},"finish_reason":"content_filter"}]}`, map[string]struct{}{}, &names)
|
||||
assert.True(t, filtered.PerformanceBusinessRejection)
|
||||
}
|
||||
Reference in new issue
Block a user