Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 1m40s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 42m16s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
195 lines
6.8 KiB
Go
195 lines
6.8 KiB
Go
package service
|
|
|
|
import (
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/model"
|
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
|
"github.com/QuantumNous/new-api/relaykit/types"
|
|
"github.com/QuantumNous/new-api/setting/operation_setting"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const requestPolicyContextKey = "request_policy_state"
|
|
|
|
type PolicyDecision struct {
|
|
Action string `json:"action"`
|
|
Reason string `json:"reason"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
type PolicyEvent struct {
|
|
Sequence int `json:"sequence,omitempty"`
|
|
Candidates []model.SelectionCandidate `json:"candidates,omitempty"`
|
|
CandidateTotal int `json:"candidate_total,omitempty"`
|
|
Attempt int `json:"attempt"`
|
|
ChannelID int `json:"channel_id,omitempty"`
|
|
Group string `json:"group,omitempty"`
|
|
Rule string `json:"rule,omitempty"`
|
|
Status int `json:"status,omitempty"`
|
|
ErrorCode string `json:"error_code,omitempty"`
|
|
ErrorSource string `json:"error_source,omitempty"`
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
Decision PolicyDecision `json:"decision"`
|
|
Health string `json:"health,omitempty"`
|
|
}
|
|
|
|
// RequestPolicyState records how one request was routed so administrators can
|
|
// read the decision flow in the log details. Channel selection and the retry
|
|
// decision stay in the relay flows; this state only records them.
|
|
type RequestPolicyState struct {
|
|
FinalLogged bool
|
|
StartedAt time.Time
|
|
Attempts int
|
|
SelectedGroup string
|
|
SessionMode string
|
|
SessionModeSource string
|
|
RuleName string
|
|
Successful bool
|
|
OutcomeRecorded bool
|
|
mu sync.Mutex
|
|
events []PolicyEvent
|
|
candidateCount int
|
|
}
|
|
|
|
func RequestPolicy(c *gin.Context) *RequestPolicyState {
|
|
if c != nil {
|
|
if value, ok := c.Get(requestPolicyContextKey); ok {
|
|
return value.(*RequestPolicyState)
|
|
}
|
|
}
|
|
state := &RequestPolicyState{StartedAt: time.Now()}
|
|
if c != nil {
|
|
c.Set(requestPolicyContextKey, state)
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (s *RequestPolicyState) AddEvent(event PolicyEvent) {
|
|
if event.Attempt == 0 {
|
|
event.Attempt = s.Attempts
|
|
}
|
|
event.ElapsedMS = time.Since(s.StartedAt).Milliseconds()
|
|
if event.Group == "" {
|
|
event.Group = s.SelectedGroup
|
|
}
|
|
if event.Rule == "" {
|
|
event.Rule = s.RuleName
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.events) < 512 {
|
|
event.Sequence = len(s.events) + 1
|
|
s.events = append(s.events, event)
|
|
}
|
|
}
|
|
|
|
func (s *RequestPolicyState) Events() []PolicyEvent {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return slices.Clone(s.events)
|
|
}
|
|
|
|
func (s *RequestPolicyState) BeginAttempt(channel *model.Channel, group string) {
|
|
s.candidateCount = 0
|
|
if s.SelectedGroup != "" && group != "" && group != s.SelectedGroup {
|
|
s.AddEvent(PolicyEvent{Attempt: s.Attempts + 1, Group: group, Decision: PolicyDecision{Action: "switch", Reason: "fallback_group_switch", Source: "routing"}})
|
|
}
|
|
s.Attempts++
|
|
s.Successful = false
|
|
s.OutcomeRecorded = false
|
|
s.SelectedGroup = group
|
|
s.AddEvent(PolicyEvent{ChannelID: channel.Id, Decision: PolicyDecision{Action: "attempt", Reason: "channel_selected", Source: "routing"}})
|
|
}
|
|
|
|
// RecordPolicyFailure appends the failed attempt and the retry decision made
|
|
// for it. The health entry mirrors the check in ProcessChannelError, which
|
|
// performs the actual disable.
|
|
func RecordPolicyFailure(c *gin.Context, channelID int, err *types.NewAPIError, decision PolicyDecision) {
|
|
if c == nil || err == nil {
|
|
return
|
|
}
|
|
source := "upstream"
|
|
switch {
|
|
case types.IsChannelError(err):
|
|
source = "channel"
|
|
case err.GetErrorCode() == types.ErrorCodeDoRequestFailed:
|
|
source = "transport"
|
|
case err.GetErrorType() == types.ErrorTypeNewAPIError:
|
|
source = "local"
|
|
}
|
|
state := RequestPolicy(c)
|
|
event := PolicyEvent{ChannelID: channelID, Status: err.StatusCode, ErrorCode: string(err.GetErrorCode()), ErrorSource: source, Decision: PolicyDecision{Action: "failure", Reason: "upstream_failure", Source: source}}
|
|
if source == "local" {
|
|
event.Decision.Reason = "local_rejection"
|
|
}
|
|
state.AddEvent(event)
|
|
event.Decision, event.Health = decision, "unchanged"
|
|
if err.GetErrorCode() != relaycommon.ResponseGuardErrorCode && source != "local" && c.GetBool("auto_ban") && ShouldDisableChannel(err) {
|
|
event.Health = "channel_disable_requested"
|
|
if common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) {
|
|
event.Health = "key_disable_requested"
|
|
}
|
|
}
|
|
state.AddEvent(event)
|
|
}
|
|
|
|
func MarkRequestPolicySuccess(c *gin.Context, stream *relaycommon.StreamStatus) {
|
|
state := RequestPolicy(c)
|
|
if state.OutcomeRecorded {
|
|
return
|
|
}
|
|
state.OutcomeRecorded = true
|
|
state.Successful = stream == nil || stream.IsNormalEnd() && !stream.HasErrors() && (stream.ResponseOutcome() == "" || stream.ResponseOutcome() == "completed")
|
|
decision := PolicyDecision{Action: "success", Reason: "request_completed", Source: "upstream"}
|
|
if !state.Successful {
|
|
decision = PolicyDecision{Action: "stop", Reason: "stream_not_successful", Source: "system"}
|
|
}
|
|
channelID := 0
|
|
if c != nil {
|
|
channelID = c.GetInt("channel_id")
|
|
}
|
|
state.AddEvent(PolicyEvent{ChannelID: channelID, Decision: decision})
|
|
}
|
|
|
|
// Rules explicitly inherit the global default or override it. Rules without a
|
|
// mode retain their legacy retry behavior until an administrator changes them.
|
|
func EffectiveSessionMode(setting *operation_setting.ChannelAffinitySetting, rule operation_setting.ChannelAffinityRule) (mode, source string) {
|
|
if rule.SessionMode == "inherit" {
|
|
if setting.SessionMode != "" {
|
|
return setting.SessionMode, "global"
|
|
}
|
|
return "prefer", "global"
|
|
}
|
|
if rule.SessionMode != "" {
|
|
return rule.SessionMode, "session_rule"
|
|
}
|
|
if rule.SkipRetryOnFailure {
|
|
return "strict", "session_rule"
|
|
}
|
|
return "prefer", "session_rule"
|
|
}
|
|
|
|
// RecordRequestPolicyTermination appends the final decision after routing has
|
|
// stopped. It never writes a log row itself: the per-channel error log written
|
|
// by ProcessChannelError already carries the decision record, so a second row
|
|
// here would duplicate it.
|
|
func RecordRequestPolicyTermination(c *gin.Context, apiErr *types.NewAPIError) {
|
|
if c == nil || apiErr == nil {
|
|
return
|
|
}
|
|
state := RequestPolicy(c)
|
|
if state.FinalLogged {
|
|
return
|
|
}
|
|
state.FinalLogged = true
|
|
events := state.Events()
|
|
if len(events) == 0 || events[len(events)-1].Decision.Action != "stop" {
|
|
state.AddEvent(PolicyEvent{ChannelID: c.GetInt("channel_id"), Status: apiErr.StatusCode, ErrorCode: string(apiErr.GetErrorCode()), Decision: PolicyDecision{Action: "stop", Reason: "request_failed", Source: "system"}, Health: "unchanged"})
|
|
}
|
|
}
|