mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
A slot that failed ten times landed in a 30-minute cooldown and was a hard
skip for the whole window: ModelAvailable() said no, the scheduler dropped the
candidate, and the ONLY way back was a success that could never happen. In
practice an upstream that blipped for a minute — or whose quota reset in
seconds — stayed unusable for half an hour.
Cooldown is now a window with a probe gate instead of a wall:
* backoffCap 30min -> 5min. 401/403 no longer implicitly "jump to the cap"
but get an explicit authCooldown (10min); 429 keeps its 30s fixed window.
* ModelState records cooldownFrom alongside cooldownUntil, so the window has
a measurable midpoint, plus a single-token probe permit (probeInFlight).
* TryProbe() hands out that permit only in the SECOND half of the window: a
freshly failed slot stays completely silent, and past the midpoint exactly
one request may go through as a probe.
* ProbeSlots() = clamp(max_concurrent/10, 1, 2), so a source configured for
max_concurrent=10 lets exactly one request probe.
* A probe is a REAL request: success runs RecordSuccess(), which clears the
cooldown and failCount on the spot and returns the slot to full rotation.
Failure reopens the window, pushing the next probe to the NEW midpoint
instead of retrying immediately.
Quota exhaustion is modelled separately (RecordQuotaExhausted): running out of
allowance is not the upstream being broken, so it must not feed the exponential
ladder or inflate failCount. It cools until the allowance window can plausibly
have reset (capped at 30min so a manual top-up is noticed) and is recognised
from the adapter-normalized error text via ReportStatusReason.
Scheduler side: collectCands() splits a tier into normal + probe candidates
with probes strictly LAST, so probe traffic is what a tier falls back to, never
what it prefers, and the round-robin cursor rotates over the normal head only.
releaseProbes() returns every permit on all exits (success, hard failure, ctx
cancel, tier fallthrough); the 90-line inlined busy-poll loop is extracted into
pollBusyTier() to keep those exits auditable. Direct Chat/ChatStream/Image take
the same gate.
Also fixes a real blacklist bug found on the way: a slot whose pref sank to
prefMin was refused by ModelAvailable forever, long after every cooldown had
expired. Such a slot is now probeable, and one success lifts it off the floor.
Verified live against a controllable upstream (tier1 flaky, tier2 healthy):
failure ladder 5s/9s/20s/40s/79s; after healing the upstream, 10 AUTO requests
across the silent half produced ZERO upstream calls; past the midpoint a single
AUTO request produced exactly ONE upstream call, cleared the remaining 39s of
cooldown, and the next three requests were served by the recovered slot.
577 lines
19 KiB
Go
577 lines
19 KiB
Go
// Package scheduler implements request scheduling across providers: direct
|
|
// fallback scheduling over candidate lists, and the AUTO chain (tiers with
|
|
// per-tier round-robin cursors, preference ordering, token-quota windows and
|
|
// per-(source,model) cooldown awareness) per the target architecture in
|
|
// plan.md.
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"llmsproxy/internal/types"
|
|
)
|
|
|
|
// busyWait is how long a fully-busy tier is polled for a free slot before the
|
|
// request falls through to the next tier (bounded wait, plan 2.3).
|
|
var busyWait = 2 * time.Second
|
|
|
|
// busyPoll is the polling interval while waiting for a busy tier.
|
|
var busyPoll = 100 * time.Millisecond
|
|
|
|
// Scheduler drives one chat tool call across the candidate provider chain.
|
|
type Scheduler struct {
|
|
// MaxRetries how many fallback providers to try before failing.
|
|
MaxRetries int
|
|
}
|
|
|
|
func New(maxRetries int) *Scheduler {
|
|
if maxRetries < 0 {
|
|
maxRetries = 0
|
|
}
|
|
return &Scheduler{MaxRetries: maxRetries}
|
|
}
|
|
|
|
// Provider is the minimal interface the scheduler needs to schedule over.
|
|
type Provider interface {
|
|
Name() string
|
|
ModelFor(reqModel string) string
|
|
ModelAvailable(model string) bool
|
|
// ModelSchedulable is the probe-aware availability gate: ok reports
|
|
// whether the model may take a request now, isProbe marks that it is only
|
|
// allowed as a cooldown probe (the caller must release the permit with
|
|
// ProbeDone once the attempt finished).
|
|
ModelSchedulable(model string) (ok bool, isProbe bool)
|
|
ProbeDone(model string)
|
|
Pref(model string) int64
|
|
Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error)
|
|
ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error)
|
|
Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error)
|
|
}
|
|
|
|
// ---- AUTO chain ----
|
|
|
|
// Rule is one persisted AUTO chain slot (mirror of config.ModelScope).
|
|
type Rule struct {
|
|
Model string
|
|
Source string
|
|
Tier int
|
|
Quota int64
|
|
Period string
|
|
Hours int64
|
|
}
|
|
|
|
// Slot is one schedulable chain position: a model pinned to its provider,
|
|
// with an optional token-quota window. Slots are immutable after build.
|
|
type Slot struct {
|
|
Model string
|
|
Source string
|
|
Quota int64
|
|
Period string
|
|
Hours int64
|
|
Prov Provider
|
|
}
|
|
|
|
// TierNode is one priority tier. Slots keep their configured order (the
|
|
// stable base for preference ordering). next is the round-robin cursor: it
|
|
// holds the last used slot index (-1 = none yet), so the very first request
|
|
// starts at the configured order and later ones rotate.
|
|
type TierNode struct {
|
|
Tier int
|
|
Slots []*Slot
|
|
next atomic.Int64
|
|
}
|
|
|
|
// NextStart advances the tier cursor and returns the start index for the next
|
|
// scheduling run (first run: index 0).
|
|
func (tn *TierNode) NextStart() int64 {
|
|
return tn.next.Add(1)
|
|
}
|
|
|
|
// Chain is the immutable AUTO scheduling plan. A rebuilt chain is swapped in
|
|
// atomically; per-tier cursors live inside the chain and are shared across
|
|
// requests (rotation state resets when the chain is rebuilt, e.g. after
|
|
// editing the rules — acceptable, the swap also resets cooldowns).
|
|
type Chain struct {
|
|
Tiers []*TierNode // ascending tier order (tier 1 = highest priority, tried first)
|
|
}
|
|
|
|
// TierErrors is the per-tier failure summary carried by ChainErr. Errors
|
|
// (TierError or skipped-tier reasons) are collected in tier order.
|
|
type TierError struct {
|
|
Tier int
|
|
Source string
|
|
Model string
|
|
Err error
|
|
}
|
|
|
|
// ChainErr is returned by chain scheduling when every AUTO tier failed. Its
|
|
// message summarizes each failed tier (which source/model and why) so a 503
|
|
// names the culprits instead of the bare "no provider available".
|
|
type ChainErr struct {
|
|
Tiers []TierError
|
|
Skipped []string // whole-tier reasons (cooling / quota / all busy)
|
|
}
|
|
|
|
func (e *ChainErr) Error() string {
|
|
var b strings.Builder
|
|
b.WriteString("all auto tiers failed: ")
|
|
first := true
|
|
for _, t := range e.Tiers {
|
|
if !first {
|
|
b.WriteString("; ")
|
|
}
|
|
first = false
|
|
fmt.Fprintf(&b, "tier %d %s/%s: %v", t.Tier, t.Source, t.Model, t.Err)
|
|
}
|
|
for _, s := range e.Skipped {
|
|
if !first {
|
|
b.WriteString("; ")
|
|
}
|
|
first = false
|
|
b.WriteString(s)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// BuildChain groups rules into ascending tiers (tier 1 = highest priority,
|
|
// tried first) and resolves each slot's provider via prov. Rules whose
|
|
// provider resolves to nil are dropped (the source no longer serves the
|
|
// model). Slot order within a tier follows the configured rule order.
|
|
func BuildChain(rules []Rule, prov func(model, source string) Provider) *Chain {
|
|
byTier := map[int][]*Slot{}
|
|
var tiers []int
|
|
for _, r := range rules {
|
|
p := prov(r.Model, r.Source)
|
|
if p == nil {
|
|
continue
|
|
}
|
|
if _, ok := byTier[r.Tier]; !ok {
|
|
tiers = append(tiers, r.Tier)
|
|
}
|
|
byTier[r.Tier] = append(byTier[r.Tier], &Slot{
|
|
Model: r.Model,
|
|
Source: r.Source,
|
|
Quota: r.Quota,
|
|
Period: r.Period,
|
|
Hours: r.Hours,
|
|
Prov: p,
|
|
})
|
|
}
|
|
sort.Slice(tiers, func(i, j int) bool { return tiers[i] < tiers[j] })
|
|
ch := &Chain{}
|
|
for _, t := range tiers {
|
|
tn := &TierNode{Tier: t, Slots: byTier[t]}
|
|
tn.next.Store(-1)
|
|
ch.Tiers = append(ch.Tiers, tn)
|
|
}
|
|
return ch
|
|
}
|
|
|
|
// tierResult is the outcome of one scheduling run over one tier.
|
|
type tierResult struct {
|
|
resp *types.UnifiedResponse
|
|
chunks <-chan types.UnifiedChunk
|
|
src string
|
|
model string
|
|
hard []TierError // hard failures seen in this pass (nil = none)
|
|
}
|
|
|
|
// candidate is one schedulable slot in a tier pass. probe marks a slot that is
|
|
// still cooling but past its half-cooldown mark and holding the probe permit:
|
|
// it is tried only after every normal slot, so probe traffic is what the tier
|
|
// falls back to instead of what it prefers.
|
|
type candidate struct {
|
|
slot *Slot
|
|
probe bool
|
|
}
|
|
|
|
// collectCands partitions a tier's slots into normal and probe candidates,
|
|
// normal first. Quota-exhausted slots are dropped outright. Every probe
|
|
// candidate returned holds a probe permit, so the caller MUST call
|
|
// releaseProbes on the result exactly once.
|
|
func collectCands(slots []*Slot, exhausted func(*Slot) bool) []candidate {
|
|
var normal, probes []candidate
|
|
for _, sl := range slots {
|
|
if exhausted != nil && exhausted(sl) {
|
|
continue
|
|
}
|
|
ok, isProbe := sl.Prov.ModelSchedulable(sl.Model)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if isProbe {
|
|
probes = append(probes, candidate{slot: sl, probe: true})
|
|
continue
|
|
}
|
|
normal = append(normal, candidate{slot: sl})
|
|
}
|
|
return append(normal, probes...)
|
|
}
|
|
|
|
// releaseProbes hands every claimed probe permit back, whether or not the probe
|
|
// slot was actually used.
|
|
func releaseProbes(cands []candidate) {
|
|
for _, c := range cands {
|
|
if c.probe {
|
|
c.slot.Prov.ProbeDone(c.slot.Model)
|
|
}
|
|
}
|
|
}
|
|
|
|
// normalCount is how many leading candidates are normal (non-probe). The
|
|
// round-robin cursor rotates only over those: probe slots are a strictly
|
|
// ordered tail, never a rotation target.
|
|
func normalCount(cands []candidate) int {
|
|
for i, c := range cands {
|
|
if c.probe {
|
|
return i
|
|
}
|
|
}
|
|
return len(cands)
|
|
}
|
|
|
|
// runTier executes one tier pass starting at the round-robin base index.
|
|
// Cooldown is the only hard skip (re-verified per slot); a busy slot is
|
|
// skipped without any penalty; a hard failure is recorded and the pass moves
|
|
// on to the next slot (plan 2.3: "单请求内不重试已失败槽" — the failed slot is
|
|
// not retried, the others still are). hard == nil and no success means every
|
|
// candidate was merely busy/cooling, so the caller may wait a bounded time.
|
|
//
|
|
// Normal candidates rotate by base; probe candidates form a fixed tail tried
|
|
// only after every normal slot failed or was busy.
|
|
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
|
|
n := len(cands)
|
|
norm := normalCount(cands)
|
|
var hard []TierError
|
|
for i := 0; i < n; i++ {
|
|
var c candidate
|
|
if i < norm {
|
|
c = cands[(int(base)+i)%norm] // rotate within the normal head
|
|
} else {
|
|
c = cands[i] // probe tail keeps its order
|
|
}
|
|
sl := c.slot
|
|
// A probe candidate is intentionally NOT re-checked here: it is cooling
|
|
// by definition, and its permit was already claimed.
|
|
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
|
|
continue
|
|
}
|
|
r := *req
|
|
r.Model = sl.Model
|
|
if stream {
|
|
chunks, err := sl.Prov.ChatStream(ctx, &r)
|
|
if err == nil {
|
|
return tierResult{chunks: chunks, src: sl.Source, model: sl.Model}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return tierResult{}
|
|
}
|
|
if errors.Is(err, types.ErrBusy) {
|
|
continue
|
|
}
|
|
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
|
continue
|
|
}
|
|
resp, err := sl.Prov.Chat(ctx, &r)
|
|
if err == nil {
|
|
return tierResult{resp: resp, src: sl.Source, model: sl.Model}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return tierResult{}
|
|
}
|
|
if errors.Is(err, types.ErrBusy) {
|
|
continue
|
|
}
|
|
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
|
}
|
|
return tierResult{hard: hard}
|
|
}
|
|
|
|
// chainDrive runs a request down the chain (plan 2.3): tiers ascending (tier
|
|
// 1, the highest priority, first), per-tier round-robin starting at the tier
|
|
// cursor, same-tier runs ordered by preference (negative prefs sink but stay
|
|
// reachable). Quota-exhausted and cooling slots are filtered up front; a
|
|
// fully busy tier is polled for a bounded time before falling through.
|
|
// Failures are summarized in *ChainErr for the caller to map to HTTP 503.
|
|
func (s *Scheduler) chainDrive(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool, stream bool) (*types.UnifiedResponse, <-chan types.UnifiedChunk, string, string, error) {
|
|
if chain == nil || len(chain.Tiers) == 0 {
|
|
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
|
}
|
|
var ce ChainErr
|
|
for _, tn := range chain.Tiers {
|
|
// initial filter: quota-exhausted slots are dropped, cooling slots are
|
|
// dropped unless they qualify as half-cooldown probes (appended last).
|
|
cands := collectCands(tn.Slots, exhausted)
|
|
if len(cands) == 0 {
|
|
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling or quota exhausted)", tn.Tier))
|
|
continue
|
|
}
|
|
// No Pref sort: load balancing is done by round-robin cursor.
|
|
// Persistently failing slots are excluded by ModelSchedulable
|
|
// (which checks Pref > prefMin).
|
|
base := tn.NextStart()
|
|
res := runTier(ctx, tn, cands, base, req, stream)
|
|
if res.resp != nil || res.chunks != nil {
|
|
releaseProbes(cands)
|
|
return res.resp, res.chunks, res.src, res.model, nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
releaseProbes(cands)
|
|
return nil, nil, "", "", ctx.Err()
|
|
}
|
|
if len(res.hard) > 0 {
|
|
ce.Tiers = append(ce.Tiers, res.hard...)
|
|
releaseProbes(cands)
|
|
continue // hard failures: fall through to the next tier, no waiting
|
|
}
|
|
// every candidate was busy or cooling: bounded poll before downgrading
|
|
if err := s.pollBusyTier(ctx, tn, cands, base, req, stream, &ce); err != nil {
|
|
releaseProbes(cands)
|
|
if r, ok := err.(*tierSuccess); ok {
|
|
return r.res.resp, r.res.chunks, r.res.src, r.res.model, nil
|
|
}
|
|
return nil, nil, "", "", err
|
|
}
|
|
releaseProbes(cands)
|
|
}
|
|
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
|
return nil, nil, "", "", fmt.Errorf("no auto slot configured")
|
|
}
|
|
return nil, nil, "", "", &ce
|
|
}
|
|
|
|
// tierSuccess carries a successful result out of pollBusyTier through the error
|
|
// return. It is never surfaced to callers of chainDrive.
|
|
type tierSuccess struct{ res tierResult }
|
|
|
|
func (t *tierSuccess) Error() string { return "tier success" }
|
|
|
|
// pollBusyTier waits a bounded time for a fully-busy tier to free a slot,
|
|
// retrying the pass while cooldowns expire. It returns nil when the tier should
|
|
// be abandoned (caller falls through to the next tier), a *tierSuccess when a
|
|
// retry succeeded, or a context error.
|
|
func (s *Scheduler) pollBusyTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool, ce *ChainErr) error {
|
|
deadline := time.Now().Add(busyWait)
|
|
timer := time.NewTimer(busyPoll)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
if time.Now().After(deadline) {
|
|
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
|
return nil
|
|
}
|
|
// refresh candidates: cooldowns may have expired meanwhile. Probe
|
|
// candidates keep their already-claimed permit and stay eligible.
|
|
var again []candidate
|
|
for _, c := range cands {
|
|
if c.probe || c.slot.Prov.ModelAvailable(c.slot.Model) {
|
|
again = append(again, c)
|
|
}
|
|
}
|
|
if len(again) == 0 {
|
|
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no free slot within %v", tn.Tier, busyWait))
|
|
return nil
|
|
}
|
|
res := runTier(ctx, tn, again, base, req, stream)
|
|
if res.resp != nil || res.chunks != nil {
|
|
return &tierSuccess{res: res}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if len(res.hard) > 0 {
|
|
ce.Tiers = append(ce.Tiers, res.hard...)
|
|
return nil // hard failure while waiting: stop waiting, fall through
|
|
}
|
|
timer.Reset(busyPoll)
|
|
}
|
|
}
|
|
|
|
// ChainChat runs a non-streaming AUTO request down the chain. exhausted, when
|
|
// non-nil, decides slot token-quota exhaustion. Returns the response, the
|
|
// serving source and the exact model id used; on total failure a *ChainErr
|
|
// summarizing every tier.
|
|
func (s *Scheduler) ChainChat(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (*types.UnifiedResponse, string, string, error) {
|
|
resp, _, src, model, err := s.chainDrive(ctx, chain, req, exhausted, false)
|
|
return resp, src, model, err
|
|
}
|
|
|
|
// ChainChatStream runs a streaming AUTO request down the chain. A slot is
|
|
// abandoned only on connect failures / busy (before its first chunk); after a
|
|
// stream starts it is pinned. Same return contract as ChainChat.
|
|
func (s *Scheduler) ChainChatStream(ctx context.Context, chain *Chain, req *types.ChatRequest, exhausted func(*Slot) bool) (<-chan types.UnifiedChunk, string, string, error) {
|
|
_, chunks, src, model, err := s.chainDrive(ctx, chain, req, exhausted, true)
|
|
return chunks, src, model, err
|
|
}
|
|
|
|
// ChainImage runs an image-generation AUTO request down the chain: tiers
|
|
// ascending (tier 1 highest priority), per-tier round-robin, same-tier order
|
|
// by preference. Each slot's model is pinned to its own image id (ModelFor),
|
|
// so a fallback switches per source. Cooling-down slots are skipped. Returns
|
|
// the response, serving source and the exact model id used; on total failure
|
|
// a *ChainErr summarizing every tier.
|
|
func (s *Scheduler) ChainImage(ctx context.Context, chain *Chain, req *types.ImageGenRequest) (*types.UnifiedResponse, string, string, error) {
|
|
if chain == nil || len(chain.Tiers) == 0 {
|
|
return nil, "", "", fmt.Errorf("no image auto slot configured")
|
|
}
|
|
var ce ChainErr
|
|
for _, tn := range chain.Tiers {
|
|
cands := collectCands(tn.Slots, nil)
|
|
if len(cands) == 0 {
|
|
ce.Skipped = append(ce.Skipped, fmt.Sprintf("tier %d: no schedulable slot (cooling)", tn.Tier))
|
|
continue
|
|
}
|
|
// No Pref sort: load balancing is done by round-robin cursor.
|
|
base := tn.NextStart()
|
|
norm := normalCount(cands)
|
|
var hard []TierError
|
|
for i := 0; i < len(cands); i++ {
|
|
var c candidate
|
|
if i < norm {
|
|
c = cands[(int(base)+i)%norm]
|
|
} else {
|
|
c = cands[i]
|
|
}
|
|
sl := c.slot
|
|
if !c.probe && !sl.Prov.ModelAvailable(sl.Model) {
|
|
continue
|
|
}
|
|
r := *req
|
|
r.Model = sl.Model
|
|
resp, err := sl.Prov.Image(ctx, &r)
|
|
if ctx.Err() != nil {
|
|
releaseProbes(cands)
|
|
return nil, "", "", ctx.Err()
|
|
}
|
|
if err == nil {
|
|
releaseProbes(cands)
|
|
return resp, sl.Source, sl.Model, nil
|
|
}
|
|
if errors.Is(err, types.ErrBusy) {
|
|
continue
|
|
}
|
|
hard = append(hard, TierError{Tier: tn.Tier, Source: sl.Source, Model: sl.Model, Err: err})
|
|
}
|
|
releaseProbes(cands)
|
|
if len(hard) > 0 {
|
|
ce.Tiers = append(ce.Tiers, hard...)
|
|
}
|
|
}
|
|
if len(ce.Tiers) == 0 && len(ce.Skipped) == 0 {
|
|
return nil, "", "", fmt.Errorf("no image auto slot configured")
|
|
}
|
|
return nil, "", "", &ce
|
|
}
|
|
|
|
// ---- direct scheduling ----
|
|
|
|
// Chat runs a chat request across cands, falling back on failure. Each
|
|
// candidate receives a request pinned to its own model (ModelFor), so a
|
|
// fallback switches the model id per provider instead of reusing the first
|
|
// candidate's model name. On success it returns the response together with
|
|
// the name of the provider and the exact model id that served the request.
|
|
// A candidate whose (source, model) pair is cooling down is skipped like a
|
|
// busy one — direct paths share the "cooldown is the only hard skip"
|
|
// semantics of the AUTO chain (plan 2.3/2.5); otherwise persistent direct
|
|
// traffic would keep renewing a capped auth cooldown forever.
|
|
func (s *Scheduler) Chat(ctx context.Context, cands []Provider, req *types.ChatRequest) (*types.UnifiedResponse, string, string, error) {
|
|
attempts := s.MaxRetries + 1
|
|
var lastErr error
|
|
for i := 0; i < attempts && i < len(cands); i++ {
|
|
p := cands[i]
|
|
r := *req
|
|
r.Model = p.ModelFor(req.Model)
|
|
ok, isProbe := p.ModelSchedulable(r.Model)
|
|
if !ok {
|
|
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
|
|
continue
|
|
}
|
|
resp, err := p.Chat(ctx, &r)
|
|
if isProbe {
|
|
p.ProbeDone(r.Model)
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil, "", "", ctx.Err()
|
|
}
|
|
if err == nil {
|
|
return resp, p.Name(), r.Model, nil
|
|
}
|
|
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
|
|
}
|
|
if lastErr == nil {
|
|
if len(cands) == 0 {
|
|
lastErr = fmt.Errorf("no provider available")
|
|
}
|
|
}
|
|
return nil, "", "", lastErr
|
|
}
|
|
|
|
// ChatStream runs a streaming chat across cands, falling back early on
|
|
// connect errors. The request model is pinned per candidate like Chat. On
|
|
// success it returns the chunk channel plus the serving provider name and
|
|
// model id.
|
|
func (s *Scheduler) ChatStream(ctx context.Context, cands []Provider, req *types.ChatRequest) (<-chan types.UnifiedChunk, string, string, error) {
|
|
attempts := s.MaxRetries + 1
|
|
var lastErr error
|
|
for i := 0; i < attempts && i < len(cands); i++ {
|
|
p := cands[i]
|
|
r := *req
|
|
r.Model = p.ModelFor(req.Model)
|
|
ok, isProbe := p.ModelSchedulable(r.Model)
|
|
if !ok {
|
|
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), r.Model)
|
|
continue
|
|
}
|
|
resp, err := p.ChatStream(ctx, &r)
|
|
if isProbe {
|
|
p.ProbeDone(r.Model)
|
|
}
|
|
if err == nil {
|
|
return resp, p.Name(), r.Model, nil
|
|
}
|
|
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
|
|
}
|
|
if lastErr == nil && len(cands) == 0 {
|
|
lastErr = fmt.Errorf("no provider available")
|
|
}
|
|
return nil, "", "", lastErr
|
|
}
|
|
|
|
// Image runs an image-generation request across cands; returns the used
|
|
// provider name on success.
|
|
func (s *Scheduler) Image(ctx context.Context, cands []Provider, req *types.ImageGenRequest) (*types.UnifiedResponse, string, error) {
|
|
attempts := s.MaxRetries + 1
|
|
var lastErr error
|
|
for i := 0; i < attempts && i < len(cands); i++ {
|
|
p := cands[i]
|
|
im := p.ModelFor(req.Model)
|
|
ok, isProbe := p.ModelSchedulable(im)
|
|
if !ok {
|
|
lastErr = fmt.Errorf("provider %s: model %q cooling down", p.Name(), im)
|
|
continue
|
|
}
|
|
resp, err := p.Image(ctx, req)
|
|
if isProbe {
|
|
p.ProbeDone(im)
|
|
}
|
|
if err == nil {
|
|
return resp, p.Name(), nil
|
|
}
|
|
lastErr = fmt.Errorf("provider %s: %w", p.Name(), err)
|
|
}
|
|
if lastErr == nil && len(cands) == 0 {
|
|
lastErr = fmt.Errorf("no provider available")
|
|
}
|
|
return nil, "", lastErr
|
|
}
|