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

+68 -5
View File
@@ -99,6 +99,11 @@ type RelayInfo struct {
UsePrice bool
RelayMode int
OriginModelName string
ResponseModel *ResponseModel
ResponseGuard *ResponseModelGuard
AstraDetectionDisabled bool
AstraAutoSwitch bool
UpstreamRequests int
// BillingModelName is the pricing identity for this request. It is kept
// separate from OriginModelName and UpstreamModelName so virtual pricing
@@ -138,7 +143,11 @@ type RelayInfo struct {
ForcePreConsume bool
// Billing 是计费会话,封装了预扣费/结算/退款的统一生命周期。
// 初始免费组可为 nil;若 auto 重试切换到付费组,会在发送前创建。
Billing BillingSettler
Billing BillingSettler
DurableAttemptBilling bool
DurableAttemptID int64
AttemptDetached bool
GuardDeadline time.Time
// BillingSource indicates whether this request is billed from wallet quota or subscription.
// "" or "wallet" => wallet; "subscription" => subscription
BillingSource string
@@ -176,6 +185,11 @@ type RelayInfo struct {
// and again before settlement. Non-nil only when billing mode is "tiered_expr".
TieredBillingSnapshot *billingexpr.BillingSnapshot
BillingRequestInput *billingexpr.RequestInput
BillingImageCount *int
// ImageRequestCount is the effective quantity sent on the current attempt;
// ImageQuotaBeforeGroup is the frozen legacy estimate before request ratios.
ImageRequestCount int
ImageQuotaBeforeGroup float64
Request dto.Request
@@ -187,6 +201,10 @@ type RelayInfo struct {
FinalRequestRelayFormat types.RelayFormat
StreamStatus *StreamStatus
// PerformanceOutputTokens is captured by settlement and sampled once at
// the request boundary, independently of billing success or failure.
PerformanceOutputTokens int64
PerformanceBusinessRejection bool
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
@@ -204,7 +222,36 @@ type RelayInfo struct {
*TaskRelayInfo
}
// UpdateImageCount replaces the billable quantity without changing the frozen
// request parameters or multiplying the legacy and expression prices together.
func (info *RelayInfo) UpdateImageCount(count int64) {
if info == nil || count <= 0 || count > int64(dto.MaxImageN) {
return
}
if info.PriceData.UsePrice {
info.PriceData.AddOtherRatio("n", float64(count))
}
if info.TieredBillingSnapshot != nil && info.TieredBillingSnapshot.EstimatedImageCount != nil {
n := int(count)
info.BillingImageCount = &n
}
}
func (info *RelayInfo) RequestedImageCount() int {
if info.ImageRequestCount > 0 {
return info.ImageRequestCount
}
if info.TieredBillingSnapshot != nil && info.TieredBillingSnapshot.EstimatedImageCount != nil {
return *info.TieredBillingSnapshot.EstimatedImageCount
}
if count, ok := info.PriceData.OtherRatios()["n"]; ok && count >= 1 && count <= dto.MaxImageN {
return int(count)
}
return 1
}
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.ResponseModel = nil
info.FinalRequestRelayFormat = ""
info.RequestConversionChain = nil
info.InitRequestConversionChain()
@@ -252,6 +299,15 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
channelMeta.ChannelOtherSettings = channelOtherSettings
}
if channelType == constant.ChannelTypeAdvancedCustom &&
!channelMeta.ChannelSetting.PassThroughBodyEnabled &&
c.Request != nil && c.Request.URL != nil {
route, matched := channelMeta.ChannelOtherSettings.AdvancedCustom.MatchPathForModel(c.Request.URL.Path, info.OriginModelName)
if matched && route.PassThroughBodyEnabled {
channelMeta.ChannelSetting.PassThroughBodyEnabled = true
}
}
if streamSupportedChannels[channelMeta.ChannelType] {
channelMeta.SupportStreamOptions = true
}
@@ -377,6 +433,8 @@ var streamSupportedChannels = map[int]bool{
constant.ChannelTypeAdvancedCustom: true,
constant.ChannelTypeSub2API: true,
constant.ChannelTypeNewAPI: true,
constant.ChannelTypeVLLM: true,
constant.ChannelTypeSGLang: true,
constant.ChannelTypeTencent: true,
}
@@ -498,6 +556,9 @@ func reasoningEffortFromRequest(request dto.Request) string {
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
config := req.GenerationConfig.ThinkingConfig
effort = config.ThinkingLevel
if canonical, err := kitreasoning.ParseEffort(effort); err == nil {
effort = string(canonical)
}
if effort == "" && config.ThinkingBudget != nil {
effort = string(kitreasoning.EffortFromBudget(*config.ThinkingBudget))
}
@@ -551,10 +612,12 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
OriginModelName: originModelName,
TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId),
TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey),
TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited),
TokenGroup: tokenGroup,
TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId),
TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey),
TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited),
TokenGroup: tokenGroup,
AstraAutoSwitch: common.GetContextKeyBool(c, constant.ContextKeyTokenAstraAutoSwitch),
AstraDetectionDisabled: common.GetContextKeyBool(c, constant.ContextKeyTokenAstraDetectionDisabled),
isFirstResponse: true,
RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path),
+98
View File
@@ -6,6 +6,8 @@ import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
@@ -147,6 +149,24 @@ func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
}},
expected: "low",
},
{
name: "Gemini uppercase enum thinking level is canonicalized",
path: "/v1beta/models/gemini-3.7-flash:generateContent",
relayFormat: types.RelayFormatGemini,
request: &dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: " MEDIUM "},
}},
expected: "medium",
},
{
name: "Gemini unknown thinking level is recorded as sent",
path: "/v1beta/models/gemini-3.7-flash:generateContent",
relayFormat: types.RelayFormatGemini,
request: &dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: "ULTRA"},
}},
expected: "ULTRA",
},
}
for _, tt := range tests {
@@ -195,6 +215,84 @@ func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
assert.Equal(t, "max", info.ReasoningEffort)
}
func TestInitChannelMetaAppliesAdvancedCustomRoutePassThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
advancedCustom := &dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
Models: []string{"native-model"},
PassThroughBodyEnabled: true,
},
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/messages",
Converter: relayconvert.ConverterOpenAIChatToClaudeMessages,
},
},
}
require.NoError(t, advancedCustom.Validate())
tests := []struct {
name string
channelType int
channelSetting dto.ChannelSettings
model string
wantPassThrough bool
wantEffort string
}{
{
name: "matched route enables pass-through and drops reasoning effort",
channelType: constant.ChannelTypeAdvancedCustom,
model: "native-model",
wantPassThrough: true,
wantEffort: "",
},
{
name: "fallback converter route keeps conversion",
channelType: constant.ChannelTypeAdvancedCustom,
model: "other-model",
wantPassThrough: false,
wantEffort: "high",
},
{
name: "channel-level pass-through still applies to converter route",
channelType: constant.ChannelTypeAdvancedCustom,
channelSetting: dto.ChannelSettings{PassThroughBodyEnabled: true},
model: "other-model",
wantPassThrough: true,
wantEffort: "",
},
{
name: "route flag is ignored for other channel types",
channelType: constant.ChannelTypeOpenAI,
model: "native-model",
wantPassThrough: false,
wantEffort: "high",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
ctx.Set("original_model", tt.model)
common.SetContextKey(ctx, constant.ContextKeyChannelType, tt.channelType)
common.SetContextKey(ctx, constant.ContextKeyChannelSetting, tt.channelSetting)
common.SetContextKey(ctx, constant.ContextKeyChannelOtherSetting, dto.ChannelOtherSettings{AdvancedCustom: advancedCustom})
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: tt.model, ReasoningEffort: "high"}, nil)
require.NoError(t, err)
info.InitChannelMeta(ctx)
assert.Equal(t, tt.wantPassThrough, info.ChannelSetting.PassThroughBodyEnabled)
assert.Equal(t, tt.wantEffort, info.ReasoningEffort)
})
}
}
func TestInitChannelMetaResetsPerAttemptStreamStateAndPreservesRequestState(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+48
View File
@@ -0,0 +1,48 @@
package common
import "strings"
// ResponseModel records upstream declarations before response conversion. It is
// diagnostic only: it must never change routing, pricing, or downstream output.
type ResponseModel struct {
RequestedModel string `json:"requested_model"`
UpstreamModel string `json:"upstream_model"`
ReturnedModel string `json:"returned_model"`
Mismatch bool `json:"mismatch"`
}
// ObserveResponseModel retains the first differing model for inspection, with
// mismatches taking priority over prefix or case-only differences. A later
// matching or empty event cannot erase it. Only observe upstream declarations,
// never models synthesized by a response converter.
func (info *RelayInfo) ObserveResponseModel(model string) {
if info == nil || strings.TrimSpace(model) == "" || (info.OriginModelName == "gpt-6-astra" && info.AstraDetectionDisabled) {
return
}
if info.ResponseModel == nil {
info.ResponseModel = &ResponseModel{
RequestedModel: info.OriginModelName,
UpstreamModel: info.GetUpstreamModelName(),
}
}
observation := info.ResponseModel
if observation.Mismatch {
return
}
mismatch := true
for _, expected := range []string{observation.RequestedModel, observation.UpstreamModel} {
if expected != "" && (strings.HasPrefix(model, expected) || strings.EqualFold(model, expected)) {
mismatch = false
break
}
}
if observation.RequestedModel == "gpt-6-astra" {
mismatch = strings.TrimSpace(model) != "gpt-6-astra"
}
if !mismatch && observation.ReturnedModel != "" &&
observation.ReturnedModel != observation.RequestedModel && observation.ReturnedModel != observation.UpstreamModel {
return
}
observation.ReturnedModel = model
observation.Mismatch = mismatch
}
+358
View File
@@ -0,0 +1,358 @@
package common
import (
"bufio"
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"time"
appcommon "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/types"
)
const ResponseGuardMaxAttempts = 3
const ResponseGuardErrorCode types.ErrorCode = "response_model_rejected"
// The guard is request-scoped, unlike channel metadata which is reset on retry.
type ResponseModelGuard struct {
Attempts []*ResponseGuardAttempt `json:"attempts"`
Deadline time.Time `json:"-"`
}
type ResponseGuardUsage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
Total int `json:"total_tokens"`
}
type ResponseGuardAttempt struct {
OnEvent func([]byte, bool) `json:"-"`
OnClose func() `json:"-"`
closeOnce sync.Once
Drain func() `json:"-"`
Number int `json:"number"`
ChannelID int `json:"channel_id"`
Expected string `json:"expected_model"`
Returned string `json:"returned_model,omitempty"`
Reason string `json:"reason"`
ElapsedMS int64 `json:"elapsed_ms"`
Usage *ResponseGuardUsage `json:"usage,omitempty"`
Delivered bool `json:"delivered"`
Status int `json:"status,omitempty"`
ResponseID string `json:"-"`
Sequence int `json:"-"`
started time.Time
timeout time.Duration
confirmed bool
err error
cancel context.CancelFunc
timer *time.Timer
stopClient func() bool
}
func (g *ResponseModelGuard) Last() *ResponseGuardAttempt {
if g == nil || len(g.Attempts) == 0 {
return nil
}
return g.Attempts[len(g.Attempts)-1]
}
func (g *ResponseModelGuard) Begin(ctx context.Context, number, channel int) (context.Context, *ResponseGuardAttempt) {
upstream, cancel := context.WithCancel(context.WithoutCancel(ctx))
a := &ResponseGuardAttempt{Number: number, ChannelID: channel, Expected: "gpt-6-astra", Reason: "pending", started: time.Now(), cancel: cancel}
// Model confirmation must allow the same initial wait as an ordinary stream.
// A separate 10-second cutoff rejects valid slow upstream responses.
a.timeout = time.Duration(constant.StreamingTimeout) * time.Second
if a.timeout <= 0 {
a.timeout = 300 * time.Second
}
if !g.Deadline.IsZero() {
a.timeout = max(time.Until(g.Deadline), time.Nanosecond)
}
a.timer = time.AfterFunc(a.timeout, cancel)
a.stopClient = context.AfterFunc(ctx, cancel)
g.Attempts = append(g.Attempts, a)
return upstream, a
}
func (a *ResponseGuardAttempt) Finish(delivered bool) {
a.ElapsedMS = time.Since(a.started).Milliseconds()
a.Delivered = delivered
}
func (a *ResponseGuardAttempt) Close() {
a.closeOnce.Do(func() {
if a.OnClose != nil {
a.OnClose()
}
a.timer.Stop()
a.stopClient()
a.cancel()
})
}
func (a *ResponseGuardAttempt) Reject(reason string) error {
if a.err == nil {
a.Reason = reason
a.err = errors.New("response model guard: " + reason)
}
return a.err
}
func (a *ResponseGuardAttempt) APIError() *types.NewAPIError {
if a == nil || a.err == nil {
return nil
}
return types.NewErrorWithStatusCode(errors.New(a.PublicErrorMessage()), ResponseGuardErrorCode, http.StatusConflict, types.ErrOptionWithSkipRetry())
}
func (a *ResponseGuardAttempt) PublicErrorMessage() string {
if a != nil && a.Reason == "model_mismatch" && a.Returned != "" {
return "Requested model " + a.Expected + ", but the upstream response declared " + a.Returned + ". No verified result could be delivered. Usage from each upstream attempt is charged independently, including rejected results."
}
if a != nil && a.Expected != "" {
return "Could not verify that the upstream response used " + a.Expected + ". Usage from each upstream attempt is charged independently, including rejected results."
}
return "Could not verify the upstream response model."
}
func (g *ResponseModelGuard) APIError() *types.NewAPIError {
if g == nil {
return nil
}
for i := len(g.Attempts) - 1; i >= 0; i-- {
attempt := g.Attempts[i]
if attempt != nil && attempt.Reason == "model_mismatch" && attempt.Returned != "" {
return attempt.APIError()
}
}
last := g.Last()
if last == nil {
return nil
}
return last.APIError()
}
// Only read the provider's declaration and numeric usage, never generated text.
func (a *ResponseGuardAttempt) observe(payload []byte, stream bool) error {
if a.OnEvent != nil {
a.OnEvent(payload, stream)
}
var event struct {
Model string `json:"model"`
ID string `json:"id"`
Sequence int `json:"sequence_number"`
Usage *ResponseGuardUsage `json:"usage"`
Response *struct {
Model string `json:"model"`
ID string `json:"id"`
Usage *ResponseGuardUsage `json:"usage"`
} `json:"response"`
}
if err := appcommon.Unmarshal(payload, &event); err != nil {
return a.Reject("invalid_response")
}
model, id, usage := event.Model, event.ID, event.Usage
if stream {
model, id, usage = "", "", nil
if event.Response != nil {
model, id, usage = event.Response.Model, event.Response.ID, event.Response.Usage
}
}
if usage != nil {
a.Usage = usage
}
a.Sequence = max(a.Sequence, event.Sequence)
model = strings.TrimSpace(model)
if model == "" {
return nil
}
// Bound diagnostics from an untrusted upstream.
a.Returned = string([]rune(model)[:min(len([]rune(model)), 128)])
if model != a.Expected {
return a.Reject("model_mismatch")
}
if id != "" {
a.ResponseID = string([]rune(id)[:min(len([]rune(id)), 256)])
}
if !a.confirmed {
if !a.timer.Stop() {
return a.Reject("confirmation_timeout")
}
if !a.stopClient() {
return a.Reject("client_cancelled")
}
a.confirmed = true
a.Reason = "accepted"
}
return nil
}
// Confirm buffers only the prefix until a provider model is known. No caller
// may commit headers or start a downstream pinger before it returns success.
func (a *ResponseGuardAttempt) Confirm(resp *http.Response, ctx context.Context) error {
a.Status = resp.StatusCode
if resp.StatusCode != http.StatusOK {
a.timer.Stop()
a.stopClient()
a.Reason = "http_error"
resp.Body = &guardCloseBody{Reader: resp.Body, body: resp.Body, close: a.Close}
return nil
}
if !strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil {
return a.readError(ctx, err)
}
if len(body) > 1<<20 {
return a.Reject("confirmation_buffer_limit")
}
if err := a.observe(body, false); err != nil {
return err
}
if !a.confirmed {
return a.Reject("missing_model")
}
_ = resp.Body.Close()
resp.Body = &guardCloseBody{Reader: bytes.NewReader(body), body: io.NopCloser(bytes.NewReader(nil)), close: a.Close}
return nil
}
b := &guardStreamBody{reader: bufio.NewReader(resp.Body), body: resp.Body, attempt: a}
a.Drain = func() {
b.draining = true
for {
if _, err := b.readFrame(8 << 20); err != nil {
return
}
}
}
var prefix bytes.Buffer
for !a.confirmed {
frame, err := b.readFrame((1 << 20) - prefix.Len())
if err != nil {
return a.readError(ctx, err)
}
prefix.Write(frame)
}
b.pending = prefix.Bytes()
resp.Body = b
return nil
}
func (a *ResponseGuardAttempt) readError(ctx context.Context, err error) error {
if a.err != nil {
return a.err
}
if ctx.Err() != nil {
return a.Reject("client_cancelled")
}
if time.Since(a.started) >= a.timeout {
return a.Reject("confirmation_timeout")
}
if errors.Is(err, io.EOF) {
return a.Reject("missing_model")
}
return a.Reject("response_read_error")
}
type guardCloseBody struct {
io.Reader
body io.Closer
close func()
once sync.Once
}
func (b *guardCloseBody) Close() error { b.once.Do(b.close); return b.body.Close() }
// Validate complete SSE records before exposing their bytes to the existing
// scanner, so a late wrong-model event cannot race past the check.
type guardStreamBody struct {
draining bool
reader *bufio.Reader
body io.ReadCloser
attempt *ResponseGuardAttempt
pending []byte
once sync.Once
}
func (b *guardStreamBody) Close() error { b.once.Do(b.attempt.Close); return b.body.Close() }
func (b *guardStreamBody) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if len(b.pending) == 0 {
frame, err := b.readFrame(128 << 20)
if err != nil {
return 0, err
}
b.pending = frame
}
n := copy(p, b.pending)
b.pending = b.pending[n:]
return n, nil
}
func (b *guardStreamBody) readFrame(limit int) ([]byte, error) {
var frame, line, data bytes.Buffer
for {
part, err := b.reader.ReadSlice('\n')
if frame.Len()+len(part) > limit {
if b.draining {
return nil, io.ErrShortBuffer
}
return nil, b.attempt.Reject("confirmation_buffer_limit")
}
frame.Write(part)
line.Write(part)
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
text := strings.TrimSuffix(strings.TrimSuffix(line.String(), "\n"), "\r")
line.Reset()
if value, ok := strings.CutPrefix(text, "data:"); ok {
if data.Len() > 0 {
data.WriteByte('\n')
}
data.WriteString(strings.TrimPrefix(value, " "))
}
if text == "" || err != nil {
if frame.Len() == 0 {
return nil, io.EOF
}
payload := bytes.TrimSpace(data.Bytes())
if len(payload) > 0 && !bytes.Equal(payload, []byte("[DONE]")) {
if b.draining {
if b.attempt.OnEvent != nil {
b.attempt.OnEvent(payload, true)
}
} else if e := b.attempt.observe(payload, true); e != nil {
return nil, e
}
}
if b.draining && bytes.Equal(payload, []byte("[DONE]")) {
return nil, io.EOF
}
if !b.attempt.confirmed && bytes.Equal(payload, []byte("[DONE]")) {
return nil, b.attempt.Reject("missing_model")
}
return frame.Bytes(), nil
}
}
}
// Detach keeps only this upstream attempt alive while a different channel is
// tried; its numeric usage is collected for at most one minute.
func (a *ResponseGuardAttempt) Detach() {
a.timer.Stop()
a.stopClient()
a.timer = time.AfterFunc(60*time.Second, a.cancel)
}
+159 -10
View File
@@ -21,6 +21,18 @@ const (
StreamEndReasonPingFail StreamEndReason = "ping_fail"
)
// ResponseOutcome is the protocol-level result of one response, independent of
// how the transport ended. Adaptors mark it from the events they already parse.
type ResponseOutcome string
const (
ResponseOutcomeUnknown ResponseOutcome = ""
ResponseOutcomeCompleted ResponseOutcome = "completed"
ResponseOutcomeFailed ResponseOutcome = "failed"
ResponseOutcomeIncomplete ResponseOutcome = "incomplete"
ResponseOutcomeCancelled ResponseOutcome = "cancelled"
)
const maxStreamErrorEntries = 20
type StreamErrorEntry struct {
@@ -31,11 +43,32 @@ type StreamErrorEntry struct {
type StreamStatus struct {
EndReason StreamEndReason
EndError error
endOnce sync.Once
endMu sync.Mutex
endSet bool
mu sync.Mutex
Errors []StreamErrorEntry
ErrorCount int
response ResponseOutcome
errorCode string
errorType string
errorStatus int
incompleteReason string
expectsTerminal bool
}
// StreamOutcome holds classification facts only; upstream messages never
// enter it because they may contain credentials or request content.
type StreamOutcome struct {
EndReason StreamEndReason
HasErrors bool
ExpectsTerminal bool
Response ResponseOutcome
ErrorCode string
ErrorType string
ErrorStatus int
IncompleteReason string
}
func NewStreamStatus() *StreamStatus {
@@ -46,10 +79,30 @@ func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) {
if s == nil {
return
}
s.endOnce.Do(func() {
s.EndReason = reason
s.endMu.Lock()
defer s.endMu.Unlock()
if s.endSet {
return
}
s.EndReason = reason
s.EndError = err
s.endSet = true
}
// SetClientGone preserves the client disconnect as the diagnostic reason when
// it races with a normal upstream terminal event. Fatal and handler-stop reasons
// remain authoritative.
func (s *StreamStatus) SetClientGone(err error) {
if s == nil {
return
}
s.endMu.Lock()
defer s.endMu.Unlock()
if !s.endSet || s.EndReason == StreamEndReasonDone || s.EndReason == StreamEndReasonEOF {
s.EndReason = StreamEndReasonClientGone
s.EndError = err
})
s.endSet = true
}
}
func (s *StreamStatus) RecordError(msg string) {
@@ -67,6 +120,95 @@ func (s *StreamStatus) RecordError(msg string) {
}
}
// RequireTerminal declares that the protocol always ends with an explicit
// terminal event, so a stream that ends without one was cut short.
func (s *StreamStatus) RequireTerminal() {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.expectsTerminal = true
}
// MarkCompleted, MarkIncomplete and MarkCancelled keep the first terminal seen;
// MarkFailed always wins because an error after completion is still a failure.
func (s *StreamStatus) MarkCompleted() {
s.markTerminal(ResponseOutcomeCompleted, "")
}
func (s *StreamStatus) MarkIncomplete(reason string) {
s.markTerminal(ResponseOutcomeIncomplete, reason)
}
func (s *StreamStatus) MarkCancelled() {
s.markTerminal(ResponseOutcomeCancelled, "")
}
func (s *StreamStatus) markTerminal(outcome ResponseOutcome, incompleteReason string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.response != ResponseOutcomeUnknown {
return
}
s.response = outcome
s.incompleteReason = incompleteReason
}
// MarkFailed records a protocol failure. Empty details never erase details
// recorded earlier, so a bare error envelope keeps the structured error.
func (s *StreamStatus) MarkFailed(code, errorType string, status int) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.response = ResponseOutcomeFailed
if code != "" {
s.errorCode = code
}
if errorType != "" {
s.errorType = errorType
}
if status != 0 {
s.errorStatus = status
}
}
func (s *StreamStatus) ResponseOutcome() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
return string(s.response)
}
func (s *StreamStatus) ResponseFailed() bool {
return s.ResponseOutcome() == string(ResponseOutcomeFailed)
}
func (s *StreamStatus) OutcomeSnapshot() StreamOutcome {
if s == nil {
return StreamOutcome{}
}
s.mu.Lock()
defer s.mu.Unlock()
return StreamOutcome{
EndReason: s.EndReason,
HasErrors: s.ErrorCount > 0,
ExpectsTerminal: s.expectsTerminal,
Response: s.response,
ErrorCode: s.errorCode,
ErrorType: s.errorType,
ErrorStatus: s.errorStatus,
IncompleteReason: s.incompleteReason,
}
}
func (s *StreamStatus) HasErrors() bool {
if s == nil {
return false
@@ -89,9 +231,12 @@ func (s *StreamStatus) IsNormalEnd() bool {
if s == nil {
return true
}
return s.EndReason == StreamEndReasonDone ||
s.EndReason == StreamEndReasonEOF ||
s.EndReason == StreamEndReasonHandlerStop
s.endMu.Lock()
reason := s.EndReason
s.endMu.Unlock()
return reason == StreamEndReasonDone ||
reason == StreamEndReasonEOF ||
reason == StreamEndReasonHandlerStop
}
func (s *StreamStatus) Summary() string {
@@ -99,9 +244,13 @@ func (s *StreamStatus) Summary() string {
return "StreamStatus<nil>"
}
b := &strings.Builder{}
fmt.Fprintf(b, "reason=%s", s.EndReason)
if s.EndError != nil {
fmt.Fprintf(b, " end_error=%q", s.EndError.Error())
s.endMu.Lock()
reason := s.EndReason
endError := s.EndError
s.endMu.Unlock()
fmt.Fprintf(b, "reason=%s", reason)
if endError != nil {
fmt.Fprintf(b, " end_error=%q", endError.Error())
}
s.mu.Lock()
if s.ErrorCount > 0 {
+11
View File
@@ -20,6 +20,17 @@ func TestStreamStatus_SetEndReason_FirstWins(t *testing.T) {
assert.Nil(t, s.EndError)
}
func TestStreamStatus_SetClientGoneOverridesNormalTerminal(t *testing.T) {
s := NewStreamStatus()
s.SetEndReason(StreamEndReasonDone, nil)
s.SetClientGone(fmt.Errorf("context canceled"))
assert.Equal(t, StreamEndReasonClientGone, s.EndReason)
assert.EqualError(t, s.EndError, "context canceled")
s.SetEndReason(StreamEndReasonHandlerStop, fmt.Errorf("fatal"))
assert.Equal(t, StreamEndReasonClientGone, s.EndReason)
}
func TestStreamStatus_SetEndReason_WithError(t *testing.T) {
t.Parallel()
s := NewStreamStatus()