From bd76aa82868f72ad147a5fac022cded5a379676c Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Thu, 25 Jun 2026 19:01:07 -0400 Subject: [PATCH] feat: env-defined endpoint aliases (http-capable, local Ollama friendly) 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_="|[|]" # base URL verbatim (http ok) GADFLY_ALIAS_="" # plain alias / failover chain Then reference them as "/" (or the bare alias) in GADFLY_MODEL(S). 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) --- README.md | 25 ++++++++- cmd/gadfly/model.go | 99 +++++++++++++++++++++++++++++++++ cmd/gadfly/model_test.go | 24 ++++++++ examples/adversarial-review.yml | 5 ++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a4aeadb..2877dd2 100644 --- a/README.md +++ b/README.md @@ -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" +``` + +`` 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 diff --git a/cmd/gadfly/model.go b/cmd/gadfly/model.go index a50f298..b3f9e41 100644 --- a/cmd/gadfly/model.go +++ b/cmd/gadfly/model.go @@ -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 + // "/" 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 +// "/" 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_ = "|[|]" +// 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_ = "" +// 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 \"|[|]\", 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) + } +} diff --git a/cmd/gadfly/model_test.go b/cmd/gadfly/model_test.go index c7f4910..3475120 100644 --- a/cmd/gadfly/model_test.go +++ b/cmd/gadfly/model_test.go @@ -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 diff --git a/examples/adversarial-review.yml b/examples/adversarial-review.yml index 62f27de..b34b0f1 100644 --- a/examples/adversarial-review.yml +++ b/examples/adversarial-review.yml @@ -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 }}