mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +00:00
191 lines
5.5 KiB
Go
191 lines
5.5 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ModelCandidate represents a provider+model+key combination for failover.
|
|
type ModelCandidate struct {
|
|
Provider string
|
|
Model string
|
|
ProfileID string // opaque identifier (never raw API key)
|
|
}
|
|
|
|
// FailoverConfig controls the failover behavior.
|
|
type FailoverConfig struct {
|
|
Candidates []ModelCandidate
|
|
Classifier ErrorClassifier
|
|
Tracker *CooldownTracker // optional cooldown tracker (nil = no cooldown)
|
|
OverloadRotationLimit int // max profile rotations on overloaded before model fallback (default 3)
|
|
MaxProfileRotations int // max total profile rotations per model (default 5)
|
|
}
|
|
|
|
// FailoverAttempt records a single failover attempt for diagnostics.
|
|
type FailoverAttempt struct {
|
|
Candidate ModelCandidate
|
|
Classification FailoverClassification
|
|
Err error
|
|
}
|
|
|
|
// FailoverSummaryError wraps all attempts when failover is exhausted.
|
|
type FailoverSummaryError struct {
|
|
Attempts []FailoverAttempt
|
|
}
|
|
|
|
func (e *FailoverSummaryError) Error() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "all %d failover candidates exhausted:", len(e.Attempts))
|
|
for i, a := range e.Attempts {
|
|
fmt.Fprintf(&b, " [%d] %s/%s: %s (%v)", i+1, a.Candidate.Provider, a.Candidate.Model, a.Classification.Reason, a.Err)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// isProfileRotatable returns true for transient errors where rotating API key/profile may help.
|
|
func isProfileRotatable(reason FailoverReason) bool {
|
|
switch reason {
|
|
case FailoverRateLimit, FailoverOverloaded, FailoverServerError, FailoverTimeout, FailoverAuth:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isModelFallbackRequired returns true for permanent errors requiring a different model.
|
|
func isModelFallbackRequired(reason FailoverReason) bool {
|
|
switch reason {
|
|
case FailoverAuthPermanent, FailoverBilling, FailoverFormat, FailoverModelNotFound, FailoverContentPolicy:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RunWithFailover executes runFn against candidates with two-tier failover:
|
|
// Tier 1: rotate profiles (same model) for transient errors.
|
|
// Tier 2: fall back to next model for permanent errors.
|
|
func RunWithFailover[T any](
|
|
ctx context.Context,
|
|
cfg FailoverConfig,
|
|
runFn func(ctx context.Context, candidate ModelCandidate) (T, error),
|
|
) (T, []FailoverAttempt, error) {
|
|
var zero T
|
|
if len(cfg.Candidates) == 0 {
|
|
return zero, nil, fmt.Errorf("failover: no candidates provided")
|
|
}
|
|
if cfg.Classifier == nil {
|
|
cfg.Classifier = NewDefaultClassifier()
|
|
}
|
|
if cfg.OverloadRotationLimit <= 0 {
|
|
cfg.OverloadRotationLimit = 3
|
|
}
|
|
if cfg.MaxProfileRotations <= 0 {
|
|
cfg.MaxProfileRotations = 5
|
|
}
|
|
|
|
var attempts []FailoverAttempt
|
|
overloadRotations := 0
|
|
profileRotations := 0
|
|
currentModel := cfg.Candidates[0].Model
|
|
|
|
for i := 0; i < len(cfg.Candidates); i++ {
|
|
if ctx.Err() != nil {
|
|
return zero, attempts, ctx.Err()
|
|
}
|
|
|
|
candidate := cfg.Candidates[i]
|
|
|
|
// Reset counters on model change
|
|
if candidate.Model != currentModel {
|
|
currentModel = candidate.Model
|
|
overloadRotations = 0
|
|
profileRotations = 0
|
|
}
|
|
|
|
// Cooldown check: skip candidates in active cooldown (unless probe allowed)
|
|
if cfg.Tracker != nil {
|
|
key := CooldownKey(candidate.Provider, candidate.Model)
|
|
if !cfg.Tracker.IsAvailable(key) && !cfg.Tracker.ShouldProbe(key) {
|
|
continue // in cooldown, no probe — skip
|
|
}
|
|
}
|
|
|
|
result, err := runFn(ctx, candidate)
|
|
if err == nil {
|
|
if cfg.Tracker != nil {
|
|
cfg.Tracker.RecordSuccess(CooldownKey(candidate.Provider, candidate.Model))
|
|
}
|
|
return result, attempts, nil
|
|
}
|
|
|
|
classification := ClassifyHTTPError(cfg.Classifier, err)
|
|
attempts = append(attempts, FailoverAttempt{
|
|
Candidate: candidate,
|
|
Classification: classification,
|
|
Err: err,
|
|
})
|
|
|
|
// Record failure in cooldown tracker
|
|
if cfg.Tracker != nil && classification.Kind == "reason" {
|
|
cfg.Tracker.RecordFailure(
|
|
CooldownKey(candidate.Provider, candidate.Model),
|
|
classification.Reason,
|
|
)
|
|
}
|
|
|
|
// Context overflow is not a failover case — return immediately for compaction
|
|
if classification.Kind == "context_overflow" {
|
|
return zero, attempts, err
|
|
}
|
|
|
|
reason := classification.Reason
|
|
|
|
// Tier 1: Profile rotation for transient errors
|
|
if isProfileRotatable(reason) {
|
|
profileRotations++
|
|
|
|
// Cap overload rotations
|
|
if reason == FailoverOverloaded {
|
|
overloadRotations++
|
|
if overloadRotations >= cfg.OverloadRotationLimit {
|
|
// Escalate to Tier 2: skip remaining profiles for this model
|
|
i = skipToNextModel(cfg.Candidates, i)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Cap total profile rotations
|
|
if profileRotations >= cfg.MaxProfileRotations {
|
|
i = skipToNextModel(cfg.Candidates, i)
|
|
continue
|
|
}
|
|
|
|
// Try next candidate (which may be same model, different profile)
|
|
continue
|
|
}
|
|
|
|
// Tier 2: Model fallback for permanent errors
|
|
if isModelFallbackRequired(reason) {
|
|
i = skipToNextModel(cfg.Candidates, i)
|
|
continue
|
|
}
|
|
|
|
// Unknown: try next candidate
|
|
continue
|
|
}
|
|
|
|
return zero, attempts, &FailoverSummaryError{Attempts: attempts}
|
|
}
|
|
|
|
// skipToNextModel advances the index past all remaining candidates with the same model.
|
|
// Returns the index of the last candidate for the current model (loop's i++ moves to next model).
|
|
func skipToNextModel(candidates []ModelCandidate, current int) int {
|
|
currentModel := candidates[current].Model
|
|
for j := current + 1; j < len(candidates); j++ {
|
|
if candidates[j].Model != currentModel {
|
|
return j - 1 // loop's i++ will advance to j
|
|
}
|
|
}
|
|
return len(candidates) - 1 // exhausted
|
|
}
|