package pipeline import ( "context" "sync" "time" ) // ratelimit.go: the per-model rate-guard for the parallel waves (WS1 §1б, ревью-2 F4). The per-CALL // 429/Retry-After backoff already lives in the transport adapter (httpllm.go); the gap the waves // open is N-CONCURRENCY against a rate-limited model — mistral-large-latest fails ~48% of calls under // parallelism (a token-bucket / concurrency cap, tier-dependent — 00-provider-quirks §Транспорт), // while grok is 0%. A model caps its own wave concurrency (max_concurrency) and, optionally, paces // call STARTS (min_interval). This is a TRANSPORT axis: it never touches the request bytes, so it is // wire-neutral and NOT snapshot-folded. Guards are built once in the precompute pass, read-only in the waves; each // guard's internals (a buffered-channel semaphore + a mutex-protected pacer) are concurrency-safe, // so acquiring from N wave goroutines is race-clean. // rateGuard limits concurrent calls to one model (+ optional pacing). The zero/nil guard is a no-op // (unlimited), so an unconfigured model — or a nil map entry — costs nothing. type rateGuard struct { sem chan struct{} // buffered to max_concurrency; nil = unlimited minInterval time.Duration // 0 = no pacing mu sync.Mutex // guards last last time.Time // start time of the most recent acquired call (pacer) } // newRateGuard builds a guard. maxConcurrency ≤ 0 → unlimited concurrency; minInterval ≤ 0 → no // pacing. Both off → newRateGuard returns a guard that acquire treats as a pure no-op. func newRateGuard(maxConcurrency int, minInterval time.Duration) *rateGuard { g := &rateGuard{minInterval: minInterval} if maxConcurrency > 0 { g.sem = make(chan struct{}, maxConcurrency) } return g } // acquire blocks until a concurrency slot is free and, if pacing is on, until the min-interval since // the last acquired call has elapsed — both respecting ctx cancellation. It returns a release func // the caller MUST defer (a no-op when acquire failed or the guard is unlimited). A nil guard (no // config) acquires instantly. The pacer holds the mutex only to read/stamp `last`; the wait itself // is outside the lock, so a paced model still admits one caller per interval without serializing the // lock for the whole wait. func (g *rateGuard) acquire(ctx context.Context) (func(), error) { if g == nil { return func() {}, nil } if g.sem != nil { select { case g.sem <- struct{}{}: case <-ctx.Done(): return func() {}, ctx.Err() } } release := func() { if g.sem != nil { <-g.sem } } if g.minInterval > 0 { for { g.mu.Lock() wait := time.Until(g.last.Add(g.minInterval)) if wait <= 0 { g.last = time.Now() g.mu.Unlock() break } g.mu.Unlock() select { case <-time.After(wait): // re-check: another caller may have taken the slot while we slept. case <-ctx.Done(): release() return func() {}, ctx.Err() } } } return release, nil } // buildRateGuards constructs a guard for every reachable model in the precompute pass (read-only in the waves). // A model with no rate_limit config gets an unlimited (no-op) guard, so rateGuard(model) is always // non-nil and lookup-safe. func (r *Runner) buildRateGuards() { r.rateGuards = make(map[string]*rateGuard, len(r.Models.Models)) for _, m := range r.reachableModels() { rl := r.Models.Models[m].RateLimit r.rateGuards[m] = newRateGuard(rl.MaxConcurrency, time.Duration(rl.MinIntervalMS)*time.Millisecond) } } // rateGuard returns the pre-built guard for a model (nil-safe: acquire treats a missing/nil guard as // unlimited, so a model reached before buildRateGuards ran simply is not throttled). func (r *Runner) rateGuard(model string) *rateGuard { return r.rateGuards[model] }