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_ entry in env. 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 }