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,120 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user