Files
steve b424261aca
executus CI / test (pull_request) Successful in 58s
Adversarial Review (Gadfly) / review (pull_request) Successful in 26m27s
executus CI / test (push) Successful in 1m2s
P1: model layer (convar->config inversion) + llmmeta
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>
2026-06-26 19:47:13 -04:00

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
}