feat: env-defined endpoint aliases (http-capable, local Ollama friendly)
Build & push image / build-and-push (push) Successful in 9s

majordomo's built-in LLM_* env DSNs are HTTPS-only (DSN.BaseURL forces https),
so they can't express a plaintext local Ollama. Add Gadfly-native env families
that register named providers/aliases with majordomo before resolution:

  GADFLY_ENDPOINT_<NAME>="<provider>|<base-url>[|<key>]"  # base URL verbatim (http ok)
  GADFLY_ALIAS_<NAME>="<majordomo spec>"                  # plain alias / failover chain

Then reference them as "<name>/<model>" (or the bare alias) in GADFLY_MODEL(S).
<NAME> lowercases to the registry name, matching majordomo's LLM_* convention.
LLM_* DSNs still work (and are documented) for HTTPS endpoints. + unit tests,
README "Endpoint aliases via env vars", stub example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steve Dudenhoeffer
2026-06-25 19:01:07 -04:00
parent d9405f4f69
commit bd76aa8286
4 changed files with 151 additions and 2 deletions
+99
View File
@@ -40,6 +40,13 @@ const defaultProvider = "ollama-cloud"
// With GADFLY_BASE_URL unset, resolution goes through majordomo's registry, so
// LLM_* env DSNs and registered aliases/tiers work too.
func resolveModel() (llm.Model, error) {
// Register any env-defined endpoints/aliases first so they're resolvable as
// "<name>/<model>" specs below. Best-effort: a malformed entry is logged and
// skipped rather than failing the whole review.
for _, err := range registerEnvProviders() {
fmt.Fprintln(os.Stderr, "gadfly: ignoring bad endpoint/alias:", err)
}
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if model == "" {
return nil, fmt.Errorf("GADFLY_MODEL is required")
@@ -98,3 +105,95 @@ func buildSpec(provider, model string) string {
}
return provider + "/" + model
}
// registerEnvProviders reads named endpoints and aliases from the environment
// and registers them with majordomo's default registry, so they can be used as
// "<name>/<model>" specs (or bare aliases) in GADFLY_MODEL.
//
// Two env families (NAME is lowercased to form the registry name, like
// majordomo's own LLM_* convention — GADFLY_ENDPOINT_BIGBOX → "bigbox"):
//
// GADFLY_ENDPOINT_<NAME> = "<provider>|<base-url>[|<key>]"
// Registers a provider at an explicit endpoint. Unlike majordomo's LLM_*
// DSNs (which are HTTPS-only), the base URL is used verbatim, so a
// plaintext local Ollama works:
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
//
// GADFLY_ALIAS_<NAME> = "<majordomo spec>"
// Registers a plain alias that expands inline (a model, or a failover
// chain): GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud".
//
// Returns one error per malformed entry; valid entries still register.
func registerEnvProviders() []error {
var errs []error
for _, kv := range os.Environ() {
key, val, _ := strings.Cut(kv, "=")
switch {
case strings.HasPrefix(key, "GADFLY_ENDPOINT_") && len(key) > len("GADFLY_ENDPOINT_"):
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ENDPOINT_"))
p, err := endpointProvider(name, val)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", key, err))
continue
}
majordomo.RegisterProvider(p)
case strings.HasPrefix(key, "GADFLY_ALIAS_") && len(key) > len("GADFLY_ALIAS_"):
name := strings.ToLower(strings.TrimPrefix(key, "GADFLY_ALIAS_"))
spec := strings.TrimSpace(val)
if spec == "" {
errs = append(errs, fmt.Errorf("%s: empty alias spec", key))
continue
}
majordomo.RegisterAlias(name, spec)
}
}
return errs
}
// endpointProvider builds a named provider from a "provider|base-url[|key]"
// value. The base URL is honored verbatim (http or https).
func endpointProvider(name, raw string) (llm.Provider, error) {
parts := strings.SplitN(raw, "|", 3)
if len(parts) < 2 {
return nil, fmt.Errorf("want \"<provider>|<base-url>[|<key>]\", got %q", raw)
}
provider := strings.TrimSpace(parts[0])
baseURL := strings.TrimSpace(parts[1])
key := ""
if len(parts) == 3 {
key = strings.TrimSpace(parts[2])
}
if baseURL == "" {
return nil, fmt.Errorf("missing base URL in %q", raw)
}
switch provider {
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithName(name), ollama.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, ollama.WithToken(key))
}
return ollama.New(opts...), nil
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
return openai.New(opts...), nil
case "anthropic":
opts := []anthropic.Option{anthropic.WithName(name), anthropic.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, anthropic.WithAPIKey(key))
}
return anthropic.New(opts...), nil
case "google", "gemini":
opts := []google.Option{google.WithName(name), google.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, google.WithAPIKey(key))
}
return google.New(opts...), nil
default:
return nil, fmt.Errorf("unknown provider %q (use ollama/openai/anthropic/google)", provider)
}
}
+24
View File
@@ -2,6 +2,30 @@ package main
import "testing"
func TestEndpointProvider(t *testing.T) {
t.Run("ollama http endpoint registers under its name", func(t *testing.T) {
p, err := endpointProvider("bigbox", "ollama|http://192.168.1.50:11434")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Name() != "bigbox" {
t.Errorf("Name() = %q, want %q", p.Name(), "bigbox")
}
})
t.Run("openai compatible with key", func(t *testing.T) {
if _, err := endpointProvider("gpu", "openai|http://gpu.lan:8000/v1|sk-x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
for _, bad := range []string{"", "ollama", "noprovider-no-pipe", "mystery|http://x"} {
t.Run("rejects "+bad, func(t *testing.T) {
if _, err := endpointProvider("n", bad); err == nil {
t.Errorf("endpointProvider(%q) = nil error, want error", bad)
}
})
}
}
func TestBuildSpec(t *testing.T) {
tests := []struct {
name string