feat: env-defined endpoint aliases (http-capable, local Ollama friendly)
Build & push image / build-and-push (push) Successful in 9s
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user