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>
This commit was merged in pull request #1.
This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user