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
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user