Files
Kai (Tam Nhu) Tran 259e754303 feat(codex-pool): split round-robin counter per modality (chat vs image) (#1021)
Previously `Registry.RoundRobinNext` used a single counter keyed by
`(tenantID, baseProviderName)`, shared across both ChatGPTOAuthRouter.Chat
and ChatGPTOAuthRouter.GenerateImage. On small pools with bursty mixed
traffic, chat calls would advance the counter and skew image-call
distribution over short windows.

Add a `modality` segment to the counter key so chat and image rotate
independently. ChatGPTOAuthRouter threads "chat" through Chat/ChatStream
and "image" through GenerateImage.

Tests (post red-team):
- ChatBurstDoesNotPerturbImageOrder — uses the issue's interleaved worked
  example (5 chat, 1 image, 4 chat, 1 image, 3 chat, 1 image) which
  produces skewed order [C, B, C] on the buggy path and even [A, B, C]
  on the fixed path. Tautological set-membership assertions were
  rejected: N consecutive image calls on an N-member pool always visit
  every member once regardless of starting offset.
- IntrospectionDoesNotAdvanceCounter — guards that Name(), DefaultModel(),
  HasAvailableProviders() call orderedProviders with advance=false.
- ChatAndImageConcurrent_CountersIndependent — 90 chat + 90 image
  goroutines concurrently; assert even per-modality distribution under
  -race.

Closes #1018
2026-04-24 06:40:51 +07:00

198 lines
6.0 KiB
Go

package providers
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"
"github.com/google/uuid"
)
// MasterTenantID is the default tenant for config-based providers.
var MasterTenantID = uuid.Must(uuid.Parse("0193a5b0-7000-7000-8000-000000000001"))
// Registry manages available LLM providers with per-tenant isolation.
// Providers are keyed by "tenantID/name". Get falls back to master tenant.
type Registry struct {
providers map[string]Provider
mu sync.RWMutex
tenantFromCtx func(context.Context) uuid.UUID // injected to avoid circular import with store
// roundRobinCounters stores shared round-robin state keyed by "tenantID/providerName/modality"
// so that ChatGPTOAuthRouter instances (created per-request) share rotation state
// within a modality (e.g. chat) while keeping distinct modalities (e.g. image)
// rotating on independent counters — see RoundRobinNext.
roundRobinMu sync.Mutex
roundRobinCounters map[string]int
}
// NewRegistry creates a provider registry.
// tenantFromCtx extracts tenant UUID from context (pass store.TenantIDFromContext).
func NewRegistry(tenantFromCtx func(context.Context) uuid.UUID) *Registry {
return &Registry{
providers: make(map[string]Provider),
tenantFromCtx: tenantFromCtx,
roundRobinCounters: make(map[string]int),
}
}
// RoundRobinNext returns the current round-robin index for the given
// (tenant, base provider, modality) triple and optionally advances it.
// Used by ChatGPTOAuthRouter to persist rotation state across per-request
// router instances. The modality segment (e.g. "chat", "image") keeps
// independent counters per modality so that bursty traffic on one modality
// cannot skew the offset used by another.
func (r *Registry) RoundRobinNext(tenantID uuid.UUID, baseProviderName, modality string, poolSize int, advance bool) int {
if poolSize <= 0 {
return 0
}
key := roundRobinKey(tenantID, baseProviderName, modality)
r.roundRobinMu.Lock()
defer r.roundRobinMu.Unlock()
idx := r.roundRobinCounters[key] % poolSize
if advance {
r.roundRobinCounters[key] = (idx + 1) % poolSize
}
return idx
}
// roundRobinKey builds the compound key for a tenant-scoped, per-modality counter.
func roundRobinKey(tenantID uuid.UUID, baseProviderName, modality string) string {
return tenantID.String() + "/" + baseProviderName + "/" + modality
}
// compoundKey returns "tenantID/name" for registry lookup.
func compoundKey(tenantID uuid.UUID, name string) string {
return tenantID.String() + "/" + name
}
// tenantFromContext resolves the tenant UUID from context, defaulting to MasterTenantID.
func (r *Registry) tenantFromContext(ctx context.Context) uuid.UUID {
if r.tenantFromCtx != nil {
if t := r.tenantFromCtx(ctx); t != uuid.Nil {
return t
}
}
return MasterTenantID
}
// Register adds a provider to the registry under the master tenant.
func (r *Registry) Register(provider Provider) {
r.RegisterForTenant(MasterTenantID, provider)
}
// RegisterForTenant adds a provider under a specific tenant.
// If a provider with the same tenant+name already exists, it is closed before replacement.
func (r *Registry) RegisterForTenant(tenantID uuid.UUID, provider Provider) {
r.mu.Lock()
defer r.mu.Unlock()
key := compoundKey(tenantID, provider.Name())
if old, ok := r.providers[key]; ok {
if c, ok := old.(io.Closer); ok {
c.Close()
}
}
r.providers[key] = provider
}
// Unregister removes a provider from the master tenant.
func (r *Registry) Unregister(name string) {
r.UnregisterForTenant(MasterTenantID, name)
}
// UnregisterForTenant removes a provider from a specific tenant.
func (r *Registry) UnregisterForTenant(tenantID uuid.UUID, name string) {
r.mu.Lock()
defer r.mu.Unlock()
key := compoundKey(tenantID, name)
if old, ok := r.providers[key]; ok {
if c, ok := old.(io.Closer); ok {
c.Close()
}
delete(r.providers, key)
}
}
// Get returns a provider by name, using tenant from context (falls back to master).
func (r *Registry) Get(ctx context.Context, name string) (Provider, error) {
return r.GetForTenant(r.tenantFromContext(ctx), name)
}
// GetForTenant returns a provider by name for a specific tenant.
// Falls back to master tenant if not found for the given tenant.
func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) {
r.mu.RLock()
defer r.mu.RUnlock()
// Try tenant-specific first
if tenantID != MasterTenantID {
if p, ok := r.providers[compoundKey(tenantID, name)]; ok {
return p, nil
}
}
// Fallback to master tenant
if p, ok := r.providers[compoundKey(MasterTenantID, name)]; ok {
return p, nil
}
return nil, fmt.Errorf("provider not found: %s", name)
}
// Close calls Close() on all providers that implement io.Closer.
func (r *Registry) Close() {
r.mu.Lock()
defer r.mu.Unlock()
for key, p := range r.providers {
if c, ok := p.(io.Closer); ok {
if err := c.Close(); err != nil {
slog.Warn("provider close error", "key", key, "error", err)
}
}
}
}
// List returns provider names visible to the tenant in context (tenant-specific + master defaults).
func (r *Registry) List(ctx context.Context) []string {
return r.ListForTenant(r.tenantFromContext(ctx))
}
// ListForTenant returns provider names available to a tenant (tenant-specific + master defaults).
func (r *Registry) ListForTenant(tenantID uuid.UUID) []string {
r.mu.RLock()
defer r.mu.RUnlock()
seen := make(map[string]bool)
var names []string
masterPrefix := MasterTenantID.String() + "/"
tenantPrefix := tenantID.String() + "/"
// Add tenant-specific providers first
if tenantID != MasterTenantID {
for key := range r.providers {
if after, ok := strings.CutPrefix(key, tenantPrefix); ok {
name := after
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
}
// Add master tenant providers (not already overridden)
for key := range r.providers {
if after, ok := strings.CutPrefix(key, masterPrefix); ok {
name := after
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return names
}