Files
majordomo/env.go
T
steve dcd004289f 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>
2026-06-10 12:35:34 +02:00

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
}