Files
majordomo/env.go
T
steveandClaude Opus 5 0760cf96d4
CI / Tidy (pull_request) Successful in 9m35s
CI / Build & Test (pull_request) Successful in 10m17s
docs: gadfly round 4 — two doc-comment fixes
DSN.Scheme's list named kimi but not qwen — the same sibling drift this PR
keeps finding, in a doc comment this time (I updated the README's scheme list
and not this one). Added qwen, and llama-swap/llama-swaps while there, since
they were missing too.

envKeyForProvider's example ran backwards: "LLM_M1 → m1" describes registry
naming, not what the function does. Now reads name → variable in one
direction.

Not taking the third: the const block's alignment is gofmt's own output
(gofmt -l is empty), and the uneven padding is forced by the doc comments that
split the block into alignment groups. glm-5.2 reached that same conclusion in
round 2 before flagging it here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:53:05 -04:00

135 lines
4.4 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://[email protected]
//
// 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", "kimi", "qwen", "anthropic",
// "google"/"gemini", "llama-swap"/"llama-swaps", 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 }
// envKeyForProvider returns the LLM_* variable that defines the provider named
// name: "m1" → LLM_M1, "my-prov" → LLM_MY_PROV.
//
// This is the single definition on purpose. Two call sites need byte-identical
// output and would drift apart in silence: lazy resolution reads this variable
// to find an unregistered provider, and openaiCompatScheme names it in the
// missing-key hint so a keyless DSN target tells the operator which variable to
// set. Those two were separate copies with a comment asserting they matched —
// a comment is not enforcement, this function is.
func envKeyForProvider(name string) string {
return "LLM_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
}
// 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
}