feat: foundations — canonical types, Parse grammar, env DSNs, health, chains
Phase 1 of the majordomo build: - llm/ canonical contract (messages, parts, tools, capabilities, streaming, Model/Provider, error classification) - health/ clock-injected tracker (threshold bench, exponential capped cooldown, reset-on-success) - root Registry + Parse (verbatim model ids, inline recursive alias expansion with cycle detection, chain dedup), LLM_* env-DSN providers (go-llm parity: lazy fallback + eager LoadEnv), health-aware chain executor behind the Model interface - provider/fake scriptable test provider; hermetic test suite incl. the trailing-thinking chain and foreman:// env loading - ADRs 0001-0008, CLAUDE.md, README (honest matrix), CI workflow, docs/phase-1-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package majordomo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// ErrAliasCycle reports a self-referential or looping alias expansion.
|
||||
var ErrAliasCycle = errors.New("alias cycle")
|
||||
|
||||
// ErrEmptySpec reports a spec with no usable elements.
|
||||
var ErrEmptySpec = errors.New("empty model spec")
|
||||
|
||||
// element is one resolved chain element: a provider name plus a verbatim
|
||||
// model id.
|
||||
type element struct {
|
||||
provider string
|
||||
model string
|
||||
}
|
||||
|
||||
func (e element) key() string { return e.provider + "/" + e.model }
|
||||
|
||||
// Parse resolves a model spec to a Model.
|
||||
//
|
||||
// Grammar:
|
||||
//
|
||||
// spec := chain
|
||||
// chain := element ("," element)*
|
||||
// element := target | alias
|
||||
// target := provider "/" model
|
||||
// alias := bare token with no slash
|
||||
//
|
||||
// The provider of a target is the first path segment; everything after the
|
||||
// first "/" (up to the next comma) is the model id and is passed to the
|
||||
// provider verbatim — "ollama-cloud/minimax-m3:cloud" keeps its tag, and
|
||||
// Google-style ids with extra slashes survive intact. Providers resolve
|
||||
// through the registry: built-ins, RegisterProvider entries, LLM_* env
|
||||
// definitions (eager or lazy), in that order.
|
||||
//
|
||||
// An alias expands to its registered spec inline, wherever it appears in a
|
||||
// chain (head, middle, or tail), recursively, with cycle detection.
|
||||
//
|
||||
// A single element and a multi-element chain return the same Model
|
||||
// interface; callers never branch on which they got. Multi-element chains
|
||||
// try elements head-to-tail with health-tracked failover (see ChainConfig
|
||||
// and the health package).
|
||||
func (r *Registry) Parse(spec string) (llm.Model, error) {
|
||||
elements, err := r.expand(spec, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(elements) == 0 {
|
||||
return nil, fmt.Errorf("%w: %q", ErrEmptySpec, spec)
|
||||
}
|
||||
|
||||
targets := make([]chainTarget, 0, len(elements))
|
||||
seen := make(map[string]bool, len(elements))
|
||||
for _, el := range elements {
|
||||
// A duplicate element (e.g. via overlapping alias expansions) would
|
||||
// just retry the same backed-off target; keep the first occurrence.
|
||||
if seen[el.key()] {
|
||||
continue
|
||||
}
|
||||
seen[el.key()] = true
|
||||
|
||||
p, err := r.providerFor(el.provider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spec %q: %w", spec, err)
|
||||
}
|
||||
m, err := p.Model(el.model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spec %q: provider %q: model %q: %w", spec, el.provider, el.model, err)
|
||||
}
|
||||
targets = append(targets, chainTarget{key: el.key(), model: m})
|
||||
}
|
||||
|
||||
return &chain{targets: targets, tracker: r.tracker, cfg: r.chainCfg}, nil
|
||||
}
|
||||
|
||||
// expand splits a spec into elements, expanding aliases inline and
|
||||
// recursively. visiting holds the alias names currently being expanded, for
|
||||
// cycle detection.
|
||||
func (r *Registry) expand(spec string, visiting []string) ([]element, error) {
|
||||
var out []element
|
||||
for raw := range strings.SplitSeq(spec, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if provider, model, hasSlash := strings.Cut(raw, "/"); hasSlash {
|
||||
out = append(out, element{provider: provider, model: model})
|
||||
continue
|
||||
}
|
||||
|
||||
// Bare token: must be a registered alias.
|
||||
r.mu.RLock()
|
||||
target, isAlias := r.aliases[raw]
|
||||
_, isProvider := r.providers[raw]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !isAlias {
|
||||
if isProvider {
|
||||
return nil, fmt.Errorf("%q is a provider, not an alias — use %q", raw, raw+"/<model-id>")
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %q is not a registered alias and has no provider/ prefix", ErrUnknownProvider, raw)
|
||||
}
|
||||
if slices.Contains(visiting, raw) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrAliasCycle, strings.Join(append(visiting, raw), " -> "))
|
||||
}
|
||||
sub, err := r.expand(target, append(visiting, raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alias %q: %w", raw, err)
|
||||
}
|
||||
out = append(out, sub...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user