feat: multi-provider model support via majordomo (local Ollama, OpenAI-compatible, etc.)
Build & push image / build-and-push (push) Successful in 18s
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:
+14
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user