dcd004289f
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>
121 lines
3.6 KiB
Go
121 lines
3.6 KiB
Go
package majordomo
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
// ErrInvalidDSN reports a malformed env-DSN value.
|
|
var ErrInvalidDSN = errors.New("invalid DSN")
|
|
|
|
// ErrUnknownProvider reports a spec element whose provider could not be
|
|
// resolved through the registry or the LLM_* environment.
|
|
var ErrUnknownProvider = errors.New("unknown provider")
|
|
|
|
// DSN is a parsed provider Data Source Name, as used in LLM_* env vars.
|
|
//
|
|
// Format (go-llm parity): scheme://[token@]host[/path]
|
|
//
|
|
// LLM_M1=foreman://test-token@foreman-m1.example.com
|
|
//
|
|
// defines provider "m1": a foreman target at https://foreman-m1.example.com
|
|
// authenticated with the bearer token "test-token".
|
|
type DSN struct {
|
|
// Scheme selects the provider implementation: "foreman", "ollama",
|
|
// "ollama-cloud", "openai", "anthropic", "google"/"gemini", or any
|
|
// custom scheme registered with RegisterScheme.
|
|
Scheme string
|
|
// Token is the provider secret (bearer token or API key); empty = none.
|
|
Token string
|
|
// Host is hostname[:port][/path] with no scheme prefix and no trailing
|
|
// slash.
|
|
Host string
|
|
}
|
|
|
|
// BaseURL returns the https base URL for the DSN host (go-llm parity:
|
|
// env-defined providers always speak TLS).
|
|
func (d DSN) BaseURL() string { return "https://" + d.Host }
|
|
|
|
// ParseDSN parses a raw DSN string. The algorithm matches go-llm exactly:
|
|
// split on "://", then an optional "@" separates the token from the host;
|
|
// trailing slashes on the host are trimmed.
|
|
func ParseDSN(raw string) (DSN, error) {
|
|
scheme, rest, found := strings.Cut(raw, "://")
|
|
if !found {
|
|
return DSN{}, fmt.Errorf("%w: missing scheme://: %q", ErrInvalidDSN, raw)
|
|
}
|
|
|
|
var token, host string
|
|
if before, after, hasAt := strings.Cut(rest, "@"); hasAt {
|
|
token = before
|
|
host = after
|
|
} else {
|
|
host = rest
|
|
}
|
|
host = strings.TrimRight(host, "/")
|
|
if host == "" {
|
|
return DSN{}, fmt.Errorf("%w: missing host: %q", ErrInvalidDSN, raw)
|
|
}
|
|
return DSN{Scheme: scheme, Token: token, Host: host}, nil
|
|
}
|
|
|
|
// LoadEnv registers a provider for every LLM_<NAME> entry in env. <NAME> is
|
|
// lowercased to form the registry name (LLM_M1 → "m1"); the value is a DSN
|
|
// whose scheme selects the factory. Entries that fail to parse are recorded
|
|
// and their error is returned (joined) — and also surfaces later if the
|
|
// name is referenced in Parse — but valid entries always register.
|
|
//
|
|
// New() calls this with the process environment; tests call it explicitly.
|
|
func (r *Registry) LoadEnv(env map[string]string) error {
|
|
// Deterministic order makes error output stable.
|
|
keys := make([]string, 0, len(env))
|
|
for k := range env {
|
|
if strings.HasPrefix(k, "LLM_") && len(k) > len("LLM_") {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var errs []error
|
|
for _, key := range keys {
|
|
name := strings.ToLower(strings.TrimPrefix(key, "LLM_"))
|
|
p, err := r.providerFromDSN(name, env[key])
|
|
if err != nil {
|
|
err = fmt.Errorf("%s: %w", key, err)
|
|
errs = append(errs, err)
|
|
r.mu.Lock()
|
|
r.envErrs[name] = err
|
|
r.mu.Unlock()
|
|
continue
|
|
}
|
|
r.mu.Lock()
|
|
r.providers[name] = p
|
|
delete(r.envErrs, name)
|
|
r.mu.Unlock()
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// providerFromDSN parses a DSN and builds a provider via its scheme factory.
|
|
func (r *Registry) providerFromDSN(name, raw string) (llm.Provider, error) {
|
|
dsn, err := ParseDSN(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.mu.RLock()
|
|
factory, ok := r.schemes[dsn.Scheme]
|
|
r.mu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: DSN scheme %q is not a registered scheme", ErrUnknownProvider, dsn.Scheme)
|
|
}
|
|
p, err := factory(name, dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scheme %q: %w", dsn.Scheme, err)
|
|
}
|
|
return p, nil
|
|
}
|