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:
@@ -0,0 +1,97 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mapSource is a tiny config.Source for tests: a key->value map, defaults
|
||||
// returned for misses.
|
||||
type mapSource map[string]string
|
||||
|
||||
func (m mapSource) String(k, d string) string {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func (m mapSource) Int(string, int) int { panic("unused") }
|
||||
func (m mapSource) Float(string, float64) float64 { panic("unused") }
|
||||
func (m mapSource) Bool(string, bool) bool { panic("unused") }
|
||||
|
||||
// TestConfigureTierResolution covers the convar->config.Source inversion: the
|
||||
// host supplies a tier table (names + fallbacks) and a live config source; the
|
||||
// config value overrides the fallback, and an absent key falls back.
|
||||
func TestConfigureTierResolution(t *testing.T) {
|
||||
Configure(
|
||||
mapSource{"model.tier.fast": "anthropic/claude-haiku-4-5"},
|
||||
map[string]string{"fast": "openai/gpt-4o-mini", "thinking": "anthropic/claude-opus-4-8"},
|
||||
time.Minute,
|
||||
)
|
||||
defer Configure(nil, nil, 0) // reset package global
|
||||
|
||||
if !IsTierName("fast") || !IsTierName("thinking") {
|
||||
t.Fatal("configured tiers should be registered")
|
||||
}
|
||||
if IsTierName("nope") {
|
||||
t.Fatal("unknown tier must not report as a tier")
|
||||
}
|
||||
if names := TierNames(); len(names) != 2 || names[0] != "fast" || names[1] != "thinking" {
|
||||
t.Fatalf("TierNames = %v, want sorted [fast thinking]", names)
|
||||
}
|
||||
|
||||
// config value overrides the host fallback
|
||||
if spec, _, ok := defaultResolver.Resolve("fast"); !ok || spec != "anthropic/claude-haiku-4-5" {
|
||||
t.Fatalf("fast resolve = %q ok=%v; config should override fallback", spec, ok)
|
||||
}
|
||||
// fallback used when config has no override for the key
|
||||
if spec, _, ok := defaultResolver.Resolve("thinking"); !ok || spec != "anthropic/claude-opus-4-8" {
|
||||
t.Fatalf("thinking resolve = %q ok=%v; should use fallback", spec, ok)
|
||||
}
|
||||
// unknown tier
|
||||
if _, _, ok := defaultResolver.Resolve("nope"); ok {
|
||||
t.Fatal("Resolve of unknown tier should be ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningSuffixOnTier verifies the reasoning-suffix dialect survives the
|
||||
// move: a tier whose spec carries ":high" yields the bare spec + level "high".
|
||||
func TestReasoningSuffixOnTier(t *testing.T) {
|
||||
Configure(nil, map[string]string{"thinking": "anthropic/claude-opus-4-8:high"}, time.Minute)
|
||||
defer Configure(nil, nil, 0)
|
||||
|
||||
spec, level, ok := defaultResolver.Resolve("thinking")
|
||||
if !ok {
|
||||
t.Fatal("thinking should resolve")
|
||||
}
|
||||
if spec != "anthropic/claude-opus-4-8" {
|
||||
t.Errorf("spec = %q, want suffix stripped", spec)
|
||||
}
|
||||
if level != "high" {
|
||||
t.Errorf("reasoning level = %q, want high", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTierValueRejectsNestedTier(t *testing.T) {
|
||||
Configure(nil, map[string]string{"fast": "x/y"}, time.Minute)
|
||||
defer Configure(nil, nil, 0)
|
||||
|
||||
if err := ValidateTierValue("fast,a/b"); err == nil {
|
||||
t.Error("a chain containing a tier alias must be rejected")
|
||||
}
|
||||
if err := ValidateTierValue("a/b,c/d"); err != nil {
|
||||
t.Errorf("a chain of concrete specs must validate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSinksDefaultNil verifies usage/trace recording is inert with no sinks
|
||||
// installed (the light-host default).
|
||||
func TestSinksDefaultNil(t *testing.T) {
|
||||
SetUsageSink(nil)
|
||||
SetTraceSink(nil)
|
||||
if TraceSinkActive() {
|
||||
t.Error("no trace sink should mean inactive")
|
||||
}
|
||||
// recordUsage must be a no-op (no panic) with a nil sink.
|
||||
recordUsage(WithModel(t.Context(), "x"), nil)
|
||||
}
|
||||
Reference in New Issue
Block a user