feat: multi-provider model support via majordomo (local Ollama, OpenAI-compatible, etc.)
Build & push image / build-and-push (push) Successful in 18s

Replace the hardcoded ollama.Cloud binding with majordomo's provider registry,
so Gadfly can target any backend majordomo supports without code changes.

- cmd/gadfly/model.go: resolveModel() — GADFLY_PROVIDER (default ollama-cloud)
  prefixes bare model ids; GADFLY_MODEL may be a full provider/model spec, alias,
  or failover chain (verbatim). GADFLY_BASE_URL constructs openai/ollama/anthropic/
  google directly at a custom endpoint (OpenAI-compatible + local/remote Ollama).
  GADFLY_API_KEY else the provider's standard env var. + buildSpec unit tests.
- run.sh: provider-aware key gate (local Ollama needs none); maps OLLAMA_CLOUD_API_KEY
  -> OLLAMA_API_KEY; provider/base-url/key inherited by the binary. Gadfly-branded comment.
- entrypoint.sh: GADFLY_MODELS alias for OLLAMA_REVIEW_MODELS; provider passthrough.
- examples + README: Models & providers section. Upfront: only the Ollama paths
  (local + OpenAI-compatible-against-Ollama) are tested; OpenAI/Anthropic/Google
  are wired via majordomo but UNTESTED (no spend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steve Dudenhoeffer
2026-06-25 18:58:00 -04:00
parent 6123604595
commit d9405f4f69
9 changed files with 370 additions and 26 deletions
+14 -12
View File
@@ -20,8 +20,16 @@
//
// Inputs (env):
//
// OLLAMA_API_KEY Ollama Cloud bearer key (required).
// GADFLY_MODEL model id, e.g. "qwen3-coder:480b-cloud" (required).
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
// alias / failover chain (required). A bare id is
// prefixed with GADFLY_PROVIDER.
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
// e.g. "ollama" for a local daemon, "openai", …).
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, remote Ollama, gateways). See model.go.
// GADFLY_API_KEY provider key; optional — falls back to the provider's
// standard env (OLLAMA_API_KEY / OPENAI_API_KEY /
// ANTHROPIC_API_KEY / GOOGLE_API_KEY|GEMINI_API_KEY).
// GADFLY_REPO_DIR path to the checked-out repo (required; the FS sandbox root).
// GADFLY_DIFF_FILE path to a file holding the full unified diff (required).
// GADFLY_SYSTEM_FILE path to the reviewer system prompt (required).
@@ -54,7 +62,6 @@ import (
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
)
const (
@@ -92,16 +99,11 @@ func main() {
}
func run() error {
apiKey := os.Getenv("OLLAMA_API_KEY")
if apiKey == "" {
return errors.New("OLLAMA_API_KEY is required")
}
model := os.Getenv("GADFLY_MODEL")
repoDir := os.Getenv("GADFLY_REPO_DIR")
diffFile := os.Getenv("GADFLY_DIFF_FILE")
systemFile := os.Getenv("GADFLY_SYSTEM_FILE")
if model == "" || repoDir == "" || diffFile == "" || systemFile == "" {
return errors.New("GADFLY_MODEL, GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required")
if repoDir == "" || diffFile == "" || systemFile == "" {
return errors.New("GADFLY_REPO_DIR, GADFLY_DIFF_FILE and GADFLY_SYSTEM_FILE are all required")
}
diffBytes, err := os.ReadFile(diffFile)
@@ -123,9 +125,9 @@ func run() error {
return err
}
mdl, err := ollama.Cloud(ollama.WithToken(apiKey)).Model(model)
mdl, err := resolveModel()
if err != nil {
return fmt.Errorf("build model %q: %w", model, err)
return fmt.Errorf("resolve model: %w", err)
}
timeout := time.Duration(envInt("GADFLY_TIMEOUT_SECS", defaultTimeoutSecs)) * time.Second
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"fmt"
"os"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/anthropic"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/google"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/openai"
)
// defaultProvider is the provider used when GADFLY_MODEL is a bare model id
// (no "provider/" prefix). It keeps existing Ollama Cloud configs — where the
// model list is just ids like "qwen3-coder:480b-cloud" — working unchanged.
const defaultProvider = "ollama-cloud"
// resolveModel builds the review model from the environment. Gadfly is powered
// by majordomo, so it can target any provider majordomo supports — Ollama
// (local or cloud), OpenAI, Anthropic, Google, or any OpenAI/Ollama-compatible
// endpoint — without code changes.
//
// Env:
//
// GADFLY_MODEL model id, or a full "provider/model" spec, or a
// majordomo failover chain / alias (required).
// GADFLY_PROVIDER provider prefix applied when GADFLY_MODEL has no "/"
// (default "ollama-cloud"). e.g. "ollama" for a local daemon.
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
// servers, a remote Ollama, an OpenRouter-style gateway…).
// When set, the provider is constructed directly at that URL.
// GADFLY_API_KEY bearer/API key for the chosen provider. Optional; when
// unset the provider falls back to its standard env var
// (OLLAMA_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
// GOOGLE_API_KEY|GEMINI_API_KEY). Local Ollama needs none.
//
// 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) {
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
if model == "" {
return nil, fmt.Errorf("GADFLY_MODEL is required")
}
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
if provider == "" {
provider = defaultProvider
}
baseURL := strings.TrimSpace(os.Getenv("GADFLY_BASE_URL"))
apiKey := os.Getenv("GADFLY_API_KEY")
// No endpoint override: let majordomo's registry resolve the spec. This
// path supports built-in providers (reading their standard key envs),
// LLM_* env DSNs, and aliases/failover chains.
if baseURL == "" {
return majordomo.Parse(buildSpec(provider, model))
}
// Endpoint override: construct the provider directly at the given URL.
switch provider {
case "openai", "openai-compatible":
opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, openai.WithAPIKey(apiKey))
}
return openai.New(opts...).Model(model)
case "ollama", "ollama-cloud":
opts := []ollama.Option{ollama.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, ollama.WithToken(apiKey))
}
return ollama.New(opts...).Model(model)
case "anthropic":
opts := []anthropic.Option{anthropic.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, anthropic.WithAPIKey(apiKey))
}
return anthropic.New(opts...).Model(model)
case "google", "gemini":
opts := []google.Option{google.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, google.WithAPIKey(apiKey))
}
return google.New(opts...).Model(model)
default:
return nil, fmt.Errorf("GADFLY_BASE_URL is set but GADFLY_PROVIDER %q has no endpoint-override support (use openai/ollama/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
}
}
// buildSpec turns (provider, model) into a majordomo spec. A model id that
// already carries a "provider/" prefix (or is a multi-element failover chain)
// is passed through verbatim; a bare id is prefixed with the provider.
func buildSpec(provider, model string) string {
if strings.Contains(model, "/") || strings.Contains(model, ",") {
return model
}
return provider + "/" + model
}
+25
View File
@@ -0,0 +1,25 @@
package main
import "testing"
func TestBuildSpec(t *testing.T) {
tests := []struct {
name string
provider string
model string
want string
}{
{"bare id gets provider prefix", "ollama-cloud", "qwen3-coder:480b-cloud", "ollama-cloud/qwen3-coder:480b-cloud"},
{"bare id local ollama", "ollama", "llama3.1", "ollama/llama3.1"},
{"already has provider passes through", "ollama-cloud", "openai/gpt-4o", "openai/gpt-4o"},
{"slashed model name passes through verbatim", "openai", "openai/meta-llama/Llama-3.1", "openai/meta-llama/Llama-3.1"},
{"failover chain passes through", "ollama-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud", "anthropic/opus-4.8,ollama-cloud/qwen3-coder:480b-cloud"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildSpec(tt.provider, tt.model); got != tt.want {
t.Errorf("buildSpec(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
}
})
}
}