Files
2026-05-23 00:16:03 +07:00

202 lines
5.9 KiB
Go

package providers
import (
"context"
)
// FallbackCandidate is one runtime provider/model fallback option.
type FallbackCandidate struct {
ProviderName string
Model string
Provider Provider
}
// ModelFallbackProvider wraps a primary provider with ordered fallback
// provider/model candidates. The primary candidate is always tried first.
type ModelFallbackProvider struct {
primary FallbackCandidate
fallbacks []FallbackCandidate
classifier ErrorClassifier
tracker *CooldownTracker
maxAttempts int
}
type FallbackCallInfo struct {
Streamed bool
}
type FallbackAfterCall func(*ChatResponse, error, FallbackCallInfo)
type FallbackBeforeCall func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (after FallbackAfterCall, err error)
func NewModelFallbackProvider(primary FallbackCandidate, fallbacks []FallbackCandidate, maxAttempts int, cooldownEnabled bool) *ModelFallbackProvider {
var tracker *CooldownTracker
if cooldownEnabled {
tracker = NewCooldownTracker(0)
}
return &ModelFallbackProvider{
primary: primary,
fallbacks: fallbacks,
classifier: NewDefaultClassifier(),
tracker: tracker,
maxAttempts: maxAttempts,
}
}
func (p *ModelFallbackProvider) PrimaryProvider() Provider {
return p.primary.Provider
}
func (p *ModelFallbackProvider) Name() string {
if p.primary.Provider != nil {
return p.primary.Provider.Name()
}
return p.primary.ProviderName
}
func (p *ModelFallbackProvider) DefaultModel() string {
if p.primary.Model != "" {
return p.primary.Model
}
if p.primary.Provider != nil {
return p.primary.Provider.DefaultModel()
}
return ""
}
func (p *ModelFallbackProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
return entry.Provider.Chat(ctx, nextReq)
})
}
func (p *ModelFallbackProvider) ChatWithHook(ctx context.Context, req ChatRequest, before FallbackBeforeCall) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
after, err := before(ctx, entry, nextReq)
if err != nil {
return nil, err
}
resp, err := entry.Provider.Chat(ctx, nextReq)
if after != nil {
after(resp, err, FallbackCallInfo{})
}
return resp, err
})
}
func (p *ModelFallbackProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
streamed := false
resp, err := entry.Provider.ChatStream(ctx, nextReq, func(chunk StreamChunk) {
if chunk.Content != "" || chunk.Thinking != "" || len(chunk.Images) > 0 {
streamed = true
}
onChunk(chunk)
})
if streamed && err != nil {
return nil, noFallbackAfterStreamError{err: err}
}
return resp, err
})
}
func (p *ModelFallbackProvider) ChatStreamWithHook(ctx context.Context, req ChatRequest, onChunk func(StreamChunk), before FallbackBeforeCall) (*ChatResponse, error) {
return p.runOrdered(ctx, req, func(ctx context.Context, entry FallbackCandidate, req ChatRequest) (*ChatResponse, error) {
nextReq := req
nextReq.Model = entry.Model
after, err := before(ctx, entry, nextReq)
if err != nil {
return nil, err
}
streamed := false
resp, err := entry.Provider.ChatStream(ctx, nextReq, func(chunk StreamChunk) {
if chunk.Content != "" || chunk.Thinking != "" || len(chunk.Images) > 0 {
streamed = true
}
onChunk(chunk)
})
if after != nil {
after(resp, err, FallbackCallInfo{Streamed: streamed})
}
if streamed && err != nil {
return nil, noFallbackAfterStreamError{err: err}
}
return resp, err
})
}
func (p *ModelFallbackProvider) runOrdered(
ctx context.Context,
req ChatRequest,
call func(context.Context, FallbackCandidate, ChatRequest) (*ChatResponse, error),
) (*ChatResponse, error) {
candidates := p.orderedCandidates(req.Model)
var attempts []FailoverAttempt
for i, entry := range candidates {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if p.maxAttempts > 0 && i >= p.maxAttempts {
break
}
key := CooldownKey(entry.ProviderName, entry.Model)
if p.tracker != nil && !p.tracker.IsAvailable(key) && !p.tracker.ShouldProbe(key) {
continue
}
resp, err := call(ctx, entry, req)
if err == nil {
if p.tracker != nil {
p.tracker.RecordSuccess(key)
}
return resp, nil
}
if streamErr, ok := err.(noFallbackAfterStreamError); ok {
return nil, streamErr.err
}
classification := ClassifyHTTPError(p.classifier, err)
attempts = append(attempts, FailoverAttempt{
Candidate: ModelCandidate{Provider: entry.ProviderName, Model: entry.Model, ProfileID: entry.ProviderName + "/" + entry.Model},
Classification: classification,
Err: err,
})
if p.tracker != nil && classification.Kind == "reason" {
p.tracker.RecordFailure(key, classification.Reason)
}
if classification.Kind == "context_overflow" || classification.Reason == FailoverUnknown {
return nil, err
}
}
return nil, &FailoverSummaryError{Attempts: attempts}
}
func (p *ModelFallbackProvider) orderedCandidates(requestModel string) []FallbackCandidate {
primary := p.primary
if requestModel != "" {
primary.Model = requestModel
}
out := []FallbackCandidate{primary}
for _, fallback := range p.fallbacks {
if fallback.Provider == nil || fallback.ProviderName == "" || fallback.Model == "" {
continue
}
if fallback.ProviderName == primary.ProviderName && fallback.Model == primary.Model {
continue
}
out = append(out, fallback)
}
return out
}
type noFallbackAfterStreamError struct {
err error
}
func (e noFallbackAfterStreamError) Error() string {
return e.err.Error()
}