b23eeb8cbf
Build & push image / build-and-push (push) Successful in 7s
Bump majordomo to the latest build and accept every llama-swap spelling (llama-swap/llama-swaps + un-hyphenated llamaswap/llamaswaps) in gadfly's endpoint switches; the LLM_* llama-swap(s):// DSN path already worked via majordomo.Parse. README + error messages + endpointProvider alias tests. Swarm review: 8/9 clean; qwen3-coder's "Blocking" was a false positive (claimed llamaswap was untested — it has dedicated test cases). Folded in its one fair nit (README now lists the un-hyphenated aliases). gofmt clean, go vet quiet, go test -race green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com> Co-committed-by: Steve Dudenhoeffer <steve@stevedudenhoeffer.com>
262 lines
11 KiB
Go
262 lines
11 KiB
Go
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/llamaswap"
|
|
"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) {
|
|
// 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")
|
|
}
|
|
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 "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
|
|
// llama-swap (model-swapping proxy). Accept every spelling: hyphenated
|
|
// ("llama-swap"/"llama-swaps") mirrors majordomo's DSN schemes (http vs
|
|
// https), and the un-hyphenated forms are accepted too. With an explicit
|
|
// GADFLY_BASE_URL the scheme is whatever the URL says, so all behave the same.
|
|
opts := []llamaswap.Option{llamaswap.WithBaseURL(baseURL)}
|
|
if apiKey != "" {
|
|
opts = append(opts, llamaswap.WithToken(apiKey))
|
|
}
|
|
return llamaswap.New(opts...).Model(model)
|
|
case "foreman":
|
|
// foreman (gitea.stevedudenhoeffer.com/steve/foreman) is a native-Ollama
|
|
// queue daemon; the preset also smooths its non-streaming/long-poll
|
|
// quirks. Base URL is used verbatim, so a plaintext http:// foreman works.
|
|
return ollama.Foreman(baseURL, apiKey).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/openai-compatible/ollama/llama-swap/foreman/anthropic/google, or unset GADFLY_BASE_URL to resolve via majordomo)", provider)
|
|
}
|
|
}
|
|
|
|
// resolveWorkerModel builds the optional worker model for delegate_investigation
|
|
// from GADFLY_WORKER_MODEL (a cheap tier is ideal). Returns (nil, nil) when
|
|
// unset — delegation is simply off. Honors GADFLY_PROVIDER for a bare id.
|
|
func resolveWorkerModel() (llm.Model, error) {
|
|
spec := strings.TrimSpace(os.Getenv("GADFLY_WORKER_MODEL"))
|
|
if spec == "" {
|
|
return nil, nil
|
|
}
|
|
provider := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER"))
|
|
if provider == "" {
|
|
provider = defaultProvider
|
|
}
|
|
return majordomo.Parse(buildSpec(provider, spec))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// modelProvider returns the provider "lane" for THIS run's model, mirroring
|
|
// entrypoint.sh's provider_of: the segment before the first "/" in GADFLY_MODEL,
|
|
// else GADFLY_PROVIDER, else the default (ollama-cloud). The binary reviews one
|
|
// model per invocation, so this is that model's provider — used to resolve
|
|
// per-provider policy (e.g. lens concurrency) against the SAME provider keys
|
|
// entrypoint uses for GADFLY_PROVIDER_CONCURRENCY.
|
|
func modelProvider() string {
|
|
model := strings.TrimSpace(os.Getenv("GADFLY_MODEL"))
|
|
if pfx, _, ok := strings.Cut(model, "/"); ok {
|
|
return strings.TrimSpace(pfx)
|
|
}
|
|
if p := strings.TrimSpace(os.Getenv("GADFLY_PROVIDER")); p != "" {
|
|
return p
|
|
}
|
|
return defaultProvider
|
|
}
|
|
|
|
// 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 (or foreman queue) works:
|
|
// GADFLY_ENDPOINT_BIGBOX="ollama|http://192.168.1.50:11434"
|
|
// GADFLY_MODEL=bigbox/qwen2.5-coder:7b
|
|
// provider is one of ollama/llama-swap(s)/foreman/openai/anthropic/google; "foreman"
|
|
// targets a foreman daemon (native Ollama on the wire):
|
|
// GADFLY_ENDPOINT_M1="foreman|http://foreman-m1:8080|tok"
|
|
//
|
|
// 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 "llamaswap", "llamaswaps", "llama-swap", "llama-swaps":
|
|
opts := []llamaswap.Option{llamaswap.WithName(name), llamaswap.WithBaseURL(baseURL)}
|
|
if key != "" {
|
|
opts = append(opts, llamaswap.WithToken(key))
|
|
}
|
|
return llamaswap.New(opts...), nil
|
|
case "foreman":
|
|
// foreman is native-Ollama on the wire; the preset additionally handles
|
|
// its non-streaming degradation. Unlike the HTTPS-only LLM_* foreman://
|
|
// DSN, the base URL here is verbatim, so a plaintext http:// foreman works.
|
|
return ollama.Foreman(baseURL, key, ollama.WithName(name)), 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/llama-swap(s)/foreman/openai/openai-compatible/anthropic/google)", provider)
|
|
}
|
|
}
|