Files
majordomo/env.go
T
steveandClaude Opus 5 8670ed22be
CI / Tidy (pull_request) Successful in 9m21s
CI / Build & Test (pull_request) Successful in 10m23s
refactor: gadfly round 3 — one table owns the OpenAI-compat contract
Three findings, and the first two are the same recurring shape.

envKeyForProvider (env.go) is now the single definition of the LLM_<NAME>
form. It lived in two places — lazy resolution in registry.go and the
missing-key hint in openaiCompatScheme — with a comment on the second asserting
it matched the first. A comment is not enforcement: if either had drifted, a
keyless DSN target would have named a variable that does nothing, and nothing
would have failed.

The kimi and qwen test files had become near-identical, which is round 1's
finding at the level above it: I deduped the fixtures, then left two parallel
suites asserting the same four things. They are now ONE table in
builtin_openaicompat_test.go — endpoint + credential, missing key fails closed
naming its own variable and never reaching the network, the name:// DSN
reaching another host, and a keyless DSN naming LLM_<NAME> instead of the
built-in's key. Adding an OpenAI-compat built-in is a table row that
immediately owes all four; builtin_kimi_test.go is deleted because the table
covers it. Only genuinely qwen-specific tests remain in the qwen file: the
reverse credential leak and the reasoning_effort wire claim ADR-0027 rests on.

Also trimmed ProviderQwen's doc comment, which restated the ADR-0027 rationale
already given at the registration site.

The break-check suite caught its own rot again — two mutations went stale when
these tests were renamed, and the landed-check reported them loudly instead of
passing them off as green. Now 9 cases, including one that drifts
envKeyForProvider to prove the shared helper is load-bearing. 9/9 apply and are
caught.

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

134 lines
4.3 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", "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 }
// envKeyForProvider returns the LLM_* variable that defines the provider named
// name (LLM_M1 → "m1", so "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
}