198 lines
6.9 KiB
Go
198 lines
6.9 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")
|
|
if state.Successful {
|
|
RecordPoolSuccess(c, c.GetString("original_model"), common.GetContextKeyString(c, constant.ContextKeyAutoGroup))
|
|
}
|
|
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"})
|
|
}
|
|
}
|