391 lines
13 KiB
Go
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())
|
|
}
|
|
}
|