Files
majordomo/parse.go
steve 0147a79d18
CI / Tidy (push) Successful in 9m31s
CI / Build & Test (push) Successful in 10m13s
feat: conversion-driven extensions — resolvers, DefineTool, hooks, ops controls
Phase 9a (ADR-0014): Registry.RegisterResolver for dynamic tiers;
DefineTool[Args] typed tools; Usage cache/reasoning detail fields wired
through anthropic/openai/google; WithPromptCaching (Anthropic
cache_control); agent supervision hooks (WithMaxStepsFunc, WithSteer,
WithCompactor, WithToolErrorLimits + ErrToolLoop); health
Bench/Unbench/Snapshot; ChainConfig.Observer failover events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:30:06 +02:00

134 lines
4.1 KiB
Go

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: a registered alias, or a dynamic resolver hit.
r.mu.RLock()
target, isAlias := r.aliases[raw]
_, isProvider := r.providers[raw]
resolvers := slices.Clone(r.resolvers)
r.mu.RUnlock()
if !isAlias {
// Resolvers run without the lock — they may call back into the
// registry (and DB-backed ones block on I/O).
for _, res := range resolvers {
if spec, ok := res.Resolve(raw); ok && spec != "" {
target, isAlias = spec, true
break
}
}
}
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
}