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
+23 -2
View File
@@ -76,8 +76,29 @@ majordomo failover chain / alias) is used verbatim.
> and exercise the exact same code an OpenAI/OpenRouter endpoint would hit, for free. If you
> try a cloud provider and it works (or doesn't), please open an issue.
For arbitrary endpoints you can also skip `GADFLY_PROVIDER`/`GADFLY_BASE_URL` and define a
majordomo `LLM_*` env DSN, then reference it by name in `GADFLY_MODEL` (advanced; HTTPS only).
### Endpoint aliases via env vars
For multiple named backends (e.g. a couple of Ollama boxes on your LAN), register them by
name with env vars and then reference `name/model` in `GADFLY_MODEL`/`GADFLY_MODELS`:
```sh
# http-capable (Gadfly-native) — base URL used verbatim, so plaintext LAN works:
GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
GADFLY_ENDPOINT_GPU="openai|http://gpu.lan:8000/v1|sk-local"
GADFLY_MODELS="bigbox/qwen2.5-coder:7b,gpu/llama3.1"
# pure spec alias (a model, or a failover chain):
GADFLY_ALIAS_FAST="bigbox/qwen2.5-coder:7b,ollama-cloud/gpt-oss:120b-cloud"
GADFLY_MODEL="fast"
```
`<NAME>` is lowercased to form the registry name (`GADFLY_ENDPOINT_BIGBOX``bigbox`). This
is the same idea as majordomo's built-in **`LLM_*` env DSNs** (`LLM_BIGBOX=ollama://tok@host`),
which Gadfly also honors — but those are **HTTPS-only**, so for plaintext local Ollama use
`GADFLY_ENDPOINT_*` instead.
> **Gitea Actions note:** repo `vars`/`secrets` aren't auto-exposed as env — add each alias to
> the stub workflow's `env:` block, e.g. `GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }}`.
### Triggers
+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
+5
View File
@@ -61,6 +61,11 @@ jobs:
# OpenAI / Anthropic / Google (supported via majordomo, UNTESTED — see README):
# GADFLY_PROVIDER: openai # then set OPENAI_API_KEY below
# OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
#
# Named endpoint aliases (reference as name/model in GADFLY_MODELS).
# vars/secrets aren't auto-exposed, so map each one explicitly:
# GADFLY_ENDPOINT_BIGBOX: ${{ vars.GADFLY_ENDPOINT_BIGBOX }} # "ollama|http://192.168.1.50:11434"
# GADFLY_MODELS: bigbox/qwen2.5-coder:7b
GADFLY_PROVIDER: ${{ vars.GADFLY_PROVIDER }}
GADFLY_BASE_URL: ${{ vars.GADFLY_BASE_URL }}
GADFLY_MODELS: ${{ vars.GADFLY_MODELS }}