feat(brain): add Gemini as brain_answer's third LLM tier
CI / Lint / Test / Vet (push) Successful in 36s
CI / Mirror to GitHub (push) Successful in 4s

llm.Router was primary-then-fallback (berget -> iguana ollama). Both
have failed together at least once in production (berget 5xx outage +
a cold fallback model exceeding the shared timeout budget), leaving
brain_answer/brain_classify fully down.

Router gains an optional Tertiary *Client, tried only if both Primary
and Fallback fail. Nil-safe — existing 2-tier deployments (no
BRAIN_LLM_TERTIARY_URL) behave exactly as before.

main.go wires it from BRAIN_LLM_TERTIARY_URL/MODEL + GEMINI_API_KEY,
pointed at Google's OpenAI-compatible Gemini endpoint
(generativelanguage.googleapis.com/v1beta/openai/) — llm.Client already
speaks OpenAI-shaped chat-completions, so no new client type needed.

Tests: TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail,
TestRouter_TertiaryNotCalledWhenFallbackSucceeds, TestRouter_AllThreeFail,
TestRouter_NilTertiary (2-tier backward-compat). Full suite green
(both modules, -race), golangci-lint clean.

Deployment env vars (infra repo, separate commit — GitOps, TBD there
too): BRAIN_LLM_TERTIARY_URL, BRAIN_LLM_TERTIARY_MODEL, GEMINI_API_KEY
secretRef.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:43:25 +02:00
co-authored by Claude Sonnet 5
parent 692244d213
commit 05330ad54a
3 changed files with 95 additions and 4 deletions
+5
View File
@@ -236,6 +236,11 @@ func main() {
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
}
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
tertiaryKey := os.Getenv("GEMINI_API_KEY")
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
}
answerComplete = router.Complete
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
}
+15 -4
View File
@@ -5,14 +5,18 @@ import (
"fmt"
)
// Router calls Primary first; on any error falls back to Fallback.
// Fallback may be nil, in which case primary errors are returned directly.
// Router calls Primary first; on any error falls back to Fallback, then to
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
// the chain returns the errors accumulated so far.
type Router struct {
Primary *Client
Fallback *Client
Tertiary *Client
}
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
// Complete implements pipeline.CompleteFunc, routing through Primary, then
// Fallback, then Tertiary — trying each configured tier in order and
// returning the first success.
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
out, err := r.Primary.Complete(ctx, system, user)
if err == nil {
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
return "", fmt.Errorf("primary llm: %w", err)
}
out, err2 := r.Fallback.Complete(ctx, system, user)
if err2 != nil {
if err2 == nil {
return out, nil
}
if r.Tertiary == nil {
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
}
out, err3 := r.Tertiary.Complete(ctx, system, user)
if err3 != nil {
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
}
return out, nil
}
+75
View File
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
_, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err)
}
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer fail.Close()
tertiary := mockServer(t, "from-tertiary")
defer tertiary.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
Tertiary: New(tertiary.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
require.NoError(t, err)
assert.Equal(t, "from-tertiary", out)
}
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer fail.Close()
fallback := mockServer(t, "from-fallback")
defer fallback.Close()
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("tertiary must not be called when fallback succeeds")
}))
defer tertiary.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fallback.URL, "", "m", time.Second),
Tertiary: New(tertiary.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
require.NoError(t, err)
assert.Equal(t, "from-fallback", out)
}
func TestRouter_AllThreeFail(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
Tertiary: New(fail.URL, "", "m", time.Second),
}
_, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err)
assert.Contains(t, err.Error(), "primary llm")
assert.Contains(t, err.Error(), "fallback llm")
assert.Contains(t, err.Error(), "tertiary llm")
}
func TestRouter_NilTertiary(t *testing.T) {
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
// exactly as before — Tertiary is opt-in, not required.
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
}
_, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err)
assert.NotContains(t, err.Error(), "tertiary")
}