Files
2026-08-04 20:52:55 +02:00

391 lines
13 KiB
Go

package ollama
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/example/glpi-ai-agent/internal/model"
)
func tagsResponse(w http.ResponseWriter, chatDigest, embedDigest string) {
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{
{"name": "m:latest", "model": "m:latest", "digest": chatDigest},
{"name": "e:latest", "model": "e:latest", "digest": embedDigest},
}})
}
func categoryResponse(w http.ResponseWriter, id int64) {
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": `{"category":{"id":` + jsonNumber(id) + `,"confidence":0.9},"reason":"ok"}`}})
}
func jsonNumber(v int64) string {
b, _ := json.Marshal(v)
return string(b)
}
func newTestPool(t *testing.T, nodes []NodeConfig, routing string) *Client {
t.Helper()
c, err := NewPool(PoolConfig{
Nodes: nodes, RoutingMode: routing, NodeMaxInflight: 1,
HealthInterval: time.Minute, FailureCooldown: time.Second,
NodeRequestTimeout: 2 * time.Second, FailoverEnabled: true, FailoverAttempts: len(nodes),
RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e",
}, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0)
if err != nil {
t.Fatal(err)
}
if err := c.Ping(context.Background()); err != nil {
t.Fatal(err)
}
return c
}
func TestPoolFailsOverAndRecordsTrace(t *testing.T) {
var badCalls atomic.Int64
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
badCalls.Add(1)
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
}))
defer bad.Close()
var goodCalls atomic.Int64
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
goodCalls.Add(1)
categoryResponse(w, 1)
}))
defer good.Close()
c := newTestPool(t, []NodeConfig{{Name: "a-bad", URL: bad.URL}, {Name: "b-good", URL: good.URL}}, "least_inflight")
ctx, trace := WithTrace(context.Background(), c.RoutingMode())
d, err := c.AnalyseCategory(ctx, model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if d.Category.ID != 1 || badCalls.Load() != 1 || goodCalls.Load() != 1 {
t.Fatalf("decision=%+v bad=%d good=%d", d, badCalls.Load(), goodCalls.Load())
}
got := trace.Snapshot()
if !got.FailoverUsed || got.SelectedNode != "b-good" || len(got.Attempts) != 2 {
t.Fatalf("unexpected trace: %+v", got)
}
if got.Attempts[0].HTTPStatus != http.StatusServiceUnavailable || !got.Attempts[0].Retryable {
t.Fatalf("unexpected first attempt: %+v", got.Attempts[0])
}
}
func TestPoolLeastInflightUsesFreeNode(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
var callsA, callsB atomic.Int64
a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
callsA.Add(1)
once.Do(func() { close(started) })
<-release
categoryResponse(w, 1)
}))
defer a.Close()
b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
callsB.Add(1)
categoryResponse(w, 1)
}))
defer b.Close()
c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "least_inflight")
errCh := make(chan error, 2)
go func() {
_, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
errCh <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("first node did not start")
}
go func() {
_, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 2}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
errCh <- err
}()
deadline := time.Now().Add(time.Second)
for callsB.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
close(release)
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil {
t.Fatal(err)
}
}
if callsA.Load() != 1 || callsB.Load() != 1 {
t.Fatalf("least-inflight distribution a=%d b=%d", callsA.Load(), callsB.Load())
}
}
func TestPoolRejectsMismatchedDigest(t *testing.T) {
server := func(chatDigest string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tagsResponse(w, chatDigest, "embed-digest")
}))
}
a := server("aaa")
defer a.Close()
b := server("bbb")
defer b.Close()
c, err := NewPool(PoolConfig{
Nodes: []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, RoutingMode: "least_inflight", NodeMaxInflight: 1,
HealthInterval: time.Minute, NodeRequestTimeout: time.Second, FailoverEnabled: true, FailoverAttempts: 2,
RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e",
}, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0)
if err != nil {
t.Fatal(err)
}
if err := c.Ping(context.Background()); err == nil {
t.Fatal("expected pool with divergent model digests to fail closed")
}
statuses := c.NodeStatuses()
for _, status := range statuses {
if status.Compatible {
t.Fatalf("mismatched node must be incompatible: %+v", statuses)
}
}
}
func TestPoolFirstRequestWaitsForInitialHealthScan(t *testing.T) {
healthStarted := make(chan struct{})
releaseHealth := make(chan struct{})
var healthCalls atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
if healthCalls.Add(1) == 1 {
close(healthStarted)
<-releaseHealth
}
tagsResponse(w, "chat-digest", "embed-digest")
return
}
categoryResponse(w, 1)
}))
defer server.Close()
c, err := NewPool(PoolConfig{
Nodes: []NodeConfig{{Name: "node-1", URL: server.URL}}, RoutingMode: "least_inflight", NodeMaxInflight: 1,
HealthInterval: time.Minute, NodeRequestTimeout: 2 * time.Second, FailoverEnabled: false, FailoverAttempts: 1,
RequireSameModelDigest: true, RequireEmbeddingModel: true, Model: "m", EmbeddingModel: "e",
}, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.Start(ctx)
select {
case <-healthStarted:
case <-time.After(time.Second):
t.Fatal("initial health scan did not start")
}
errCh := make(chan error, 1)
go func() {
_, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
errCh <- err
}()
select {
case err := <-errCh:
t.Fatalf("request returned before health scan completed: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(releaseHealth)
select {
case err := <-errCh:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("request did not continue after health scan")
}
}
func TestPoolFailsOverOnInvalidOuterJSON(t *testing.T) {
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":`))
}))
defer bad.Close()
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
categoryResponse(w, 1)
}))
defer good.Close()
c := newTestPool(t, []NodeConfig{{Name: "a-bad-json", URL: bad.URL}, {Name: "b-good", URL: good.URL}}, "least_inflight")
ctx, trace := WithTrace(context.Background(), c.RoutingMode())
if _, err := c.AnalyseCategory(ctx, model.Ticket{ID: 1}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil {
t.Fatal(err)
}
got := trace.Snapshot()
if !got.FailoverUsed || got.SelectedNode != "b-good" || len(got.Attempts) != 2 {
t.Fatalf("unexpected trace: %+v", got)
}
if got.Attempts[0].Outcome != "error" || !got.Attempts[0].Retryable || got.Attempts[0].Error == "" {
t.Fatalf("invalid JSON must be recorded as retryable error: %+v", got.Attempts[0])
}
}
func TestPoolAllowsChatOnlyNodeButRoutesEmbeddingsToCapableNode(t *testing.T) {
var chatOnlyEmbedCalls atomic.Int64
chatOnly := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "m:latest", "model": "m:latest", "digest": "chat-digest"}}})
return
}
if r.URL.Path == "/api/embed" {
chatOnlyEmbedCalls.Add(1)
}
categoryResponse(w, 1)
}))
defer chatOnly.Close()
var embeddingCalls atomic.Int64
embeddingNode := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
if r.URL.Path == "/api/embed" {
embeddingCalls.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float64{{0.1, 0.2}}})
return
}
categoryResponse(w, 1)
}))
defer embeddingNode.Close()
c, err := NewPool(PoolConfig{
Nodes: []NodeConfig{{Name: "chat-only", URL: chatOnly.URL}, {Name: "embedding", URL: embeddingNode.URL}},
RoutingMode: "least_inflight", NodeMaxInflight: 1, HealthInterval: time.Minute,
NodeRequestTimeout: time.Second, FailoverEnabled: true, FailoverAttempts: 2,
RequireSameModelDigest: true, RequireEmbeddingModel: false, Model: "m", EmbeddingModel: "e",
}, "m", "e", "de-DE", "formal", 128, time.Minute, false, 0)
if err != nil {
t.Fatal(err)
}
if err := c.Ping(context.Background()); err != nil {
t.Fatal(err)
}
vectors, err := c.Embed(context.Background(), []string{"test"})
if err != nil {
t.Fatal(err)
}
if len(vectors) != 1 || embeddingCalls.Load() != 1 || chatOnlyEmbedCalls.Load() != 0 {
t.Fatalf("vectors=%v embedding_calls=%d chat_only_embed_calls=%d", vectors, embeddingCalls.Load(), chatOnlyEmbedCalls.Load())
}
}
func TestPoolLeastInflightBalancesSerialRequests(t *testing.T) {
var callsA, callsB atomic.Int64
server := func(calls *atomic.Int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
calls.Add(1)
categoryResponse(w, 1)
}))
}
a := server(&callsA)
defer a.Close()
b := server(&callsB)
defer b.Close()
c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "least_inflight")
for i := 0; i < 4; i++ {
if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil {
t.Fatal(err)
}
}
if callsA.Load() != 2 || callsB.Load() != 2 {
t.Fatalf("serial least-inflight distribution a=%d b=%d", callsA.Load(), callsB.Load())
}
}
func TestPoolWeightedBalancesSerialRequestsByWeight(t *testing.T) {
var callsA, callsB atomic.Int64
server := func(calls *atomic.Int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
calls.Add(1)
categoryResponse(w, 1)
}))
}
a := server(&callsA)
defer a.Close()
b := server(&callsB)
defer b.Close()
c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL, Weight: 1}, {Name: "b", URL: b.URL, Weight: 3}}, "weighted")
for i := 0; i < 8; i++ {
if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil {
t.Fatal(err)
}
}
if callsA.Load() == 0 || callsB.Load() <= callsA.Load() {
t.Fatalf("weighted distribution must use both nodes and prefer weight 3: a=%d b=%d", callsA.Load(), callsB.Load())
}
}
func TestPoolFastestRecentProbesUnmeasuredNodes(t *testing.T) {
var callsA, callsB atomic.Int64
server := func(calls *atomic.Int64) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
tagsResponse(w, "chat-digest", "embed-digest")
return
}
calls.Add(1)
categoryResponse(w, 1)
}))
}
a := server(&callsA)
defer a.Close()
b := server(&callsB)
defer b.Close()
c := newTestPool(t, []NodeConfig{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}}, "fastest_recent")
for i := 0; i < 2; i++ {
if _, err := c.AnalyseCategory(context.Background(), model.Ticket{ID: int64(i + 1)}, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{}); err != nil {
t.Fatal(err)
}
}
if callsA.Load() != 1 || callsB.Load() != 1 {
t.Fatalf("fastest_recent must measure both nodes before preferring one: a=%d b=%d", callsA.Load(), callsB.Load())
}
}