Files

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"})
}
}