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:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+36 -8
View File
@@ -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)
}
+11 -1
View File
@@ -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 != "" {
+3 -2
View File
@@ -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
+184
View File
@@ -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.
+15 -3
View File
@@ -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
}
+66 -33
View File
@@ -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()
}
+94 -72
View File
@@ -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)
}