b424261aca
Lifts mort's pkg/logic/llms into executus/model, decoupled from mort: - tiers.go: the tier resolver now reads a host-supplied config.Source under "model.tier.<name>" with host-supplied fallbacks (Configure(cfg, defaults, ttl)), instead of convar.Manager. Tier NAMES + specs are host config; the resolution mechanism (cache, reasoning-suffix dialect, chain validation) is generic. No tier names hard-coded in the harness. - sink.go: usage/trace recording inverted off mort's llmusage/llmtrace into UsageSink / TraceSink seams + a model-owned Span, with nil-safe context attribution helpers (WithModel/WithTraceID/WithUsageTool/WithUsageUser). Both sinks optional (nil = off) so a light host records nothing. - lane decoration repointed to executus/lane; utils.Errorf -> fmt.Errorf. - call.go keeps GenerateWith[T] (instrumented structured output) — this is the structured-output primitive; no separate structured/ package. - llmmeta moved over model/ (the meta-LLM helper: tier allowlist + JSON retry + ledger). Its tests configure a minimal tier table via TestMain. New tests cover the inversion: config overrides fallback, tier registration, reasoning-suffix survival, nested-tier rejection, nil-sink no-ops. Full module: go build/vet/test -race green; core go.sum still free of gorm/redis/discordgo/sqlite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
5.5 KiB
Go
163 lines
5.5 KiB
Go
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.<name>", 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
|
|
}
|