Files
gadfly/cmd/gadfly/model.go
T
steve b23eeb8cbf
Build & push image / build-and-push (push) Successful in 7s
feat: bump majordomo + support llama-swap(s) provider spellings (#7)
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>
2026-06-27 23:18:56 +00:00

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)
}
}