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+"/") } 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 }