package model import ( "fmt" "sort" "strings" "sync" "time" "gitea.stevedudenhoeffer.com/steve/executus/config" ) // tierResolver expands tier aliases (e.g. "fast", "thinking", "agent-working") // into a concrete model spec or a comma-separated failover chain. The set of // tier names and their FALLBACK specs are host-supplied (a map passed at // Configure time); the live value of each tier is read from a config.Source // under the key "model.tier.", so a host whose config backend mutates at // runtime (mort's convar) re-targets tiers without a restart, while a static // host (gadfly's env) just gets the fallback. A small in-process cache (TTL // from "model.tier.cache_ttl_seconds", default 30s) saves config round-trips on // the hot path; ReloadTiers clears it. // // This is executus's inversion of mort's convar-bound resolver: the MECHANISM // (tier lookup, reasoning-suffix dialect, chain validation, cache) is generic; // the tier MAP content (which tiers exist + their default specs) is host config. type tierResolver struct { cfg config.Source defaults map[string]string // tier name -> fallback spec ttl time.Duration mu sync.RWMutex cache map[string]tierEntry now func() time.Time // overridable for tests } type tierEntry struct { spec string reasoning string expires time.Time } const tierConfigPrefix = "model.tier." // NewTierResolver builds a resolver over cfg with the given tier defaults // (name -> fallback spec). cfg may be nil (the fallbacks are then always used). // ttl<=0 reads "model.tier.cache_ttl_seconds" (default 30s). func NewTierResolver(cfg config.Source, defaults map[string]string, ttl time.Duration) *tierResolver { if ttl <= 0 { ttl = time.Duration(config.Int(cfg, tierConfigPrefix+"cache_ttl_seconds", 30)) * time.Second } if ttl <= 0 { ttl = 30 * time.Second } cp := make(map[string]string, len(defaults)) for k, v := range defaults { cp[k] = v } return &tierResolver{ cfg: cfg, defaults: cp, ttl: ttl, cache: make(map[string]tierEntry), now: time.Now, } } func (r *tierResolver) has(name string) bool { _, ok := r.defaults[name] return ok } func (r *tierResolver) names() []string { out := make([]string, 0, len(r.defaults)) for k := range r.defaults { out = append(out, k) } sort.Strings(out) return out } // Resolve returns the current model spec and default reasoning level for a tier // name. ok=false if name is not a registered tier. Legacy reasoning suffixes // (":low/:medium/:high") are stripped per chain element; the first non-empty // level becomes the tier's default reasoning level (ollama tags like ":cloud" // pass through). The live value is read from config with the host-supplied // fallback; an empty resolved value yields ok=true with an empty spec // (ParseModelRequest surfaces a clear error in that path). func (r *tierResolver) Resolve(name string) (string, string, bool) { if !r.has(name) { return "", "", false } now := r.now() r.mu.RLock() if e, hit := r.cache[name]; hit && now.Before(e.expires) { r.mu.RUnlock() return e.spec, e.reasoning, true } r.mu.RUnlock() r.mu.Lock() defer r.mu.Unlock() if e, hit := r.cache[name]; hit && now.Before(e.expires) { return e.spec, e.reasoning, true } raw := strings.TrimSpace(config.String(r.cfg, tierConfigPrefix+name, r.defaults[name])) spec, level := splitReasoningSpec(raw) r.cache[name] = tierEntry{spec: spec, reasoning: level, expires: now.Add(r.ttl)} return spec, level, true } // Reload clears the cache so the next Resolve fetches fresh from config. func (r *tierResolver) Reload() { r.mu.Lock() defer r.mu.Unlock() r.cache = make(map[string]tierEntry) } // --- package-level resolver + facade --- // defaultResolver is initialized as a package-level var (not in init()) so it // is ready before any other file's init runs — buildRegistry's delegating // resolver closure reads it at Resolve time. It starts with no tiers; a host // installs its tier table via Configure. var defaultResolver = NewTierResolver(nil, nil, 0) // Configure installs the host's tier table. cfg is the live config source // (nil = fallbacks only); defaults maps each tier name to its fallback spec; // ttl<=0 uses the config'd / 30s default. The package registry's delegating // resolver reads defaultResolver at Resolve time, so swapping it here is // sufficient — no registry rebuild needed. func Configure(cfg config.Source, defaults map[string]string, ttl time.Duration) { defaultResolver = NewTierResolver(cfg, defaults, ttl) } // TierNames returns the registered tier alias names (sorted). Exported so UI // layers can populate tier dropdowns without hardcoding. func TierNames() []string { return defaultResolver.names() } // IsTierName reports whether s is a registered tier alias. func IsTierName(s string) bool { return defaultResolver.has(s) } // ReloadTiers clears the package resolver's cache so the next request resolves // freshly from config. func ReloadTiers() { defaultResolver.Reload() } // ValidateTierValue returns an error if value cannot be used as a tier spec — // specifically, when a chain entry is itself a tier name (which would form a // resolution loop). Chain entries must be concrete provider/model specs. func ValidateTierValue(value string) error { for _, part := range strings.Split(value, ",") { part = strings.TrimSpace(part) if part == "" { continue } spec, _ := splitReasoning(part) if IsTierName(spec) { return fmt.Errorf("tier value %q contains tier alias %q (chains must use concrete provider/model specs, not nested tiers)", value, spec) } } return nil }