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

784 lines
22 KiB
Go

package ollama
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/example/glpi-ai-agent/internal/model"
)
const providerName = "ollama-pool"
type NodeConfig struct {
Name string
URL string
Weight int
}
type PoolConfig struct {
Nodes []NodeConfig
RoutingMode string
NodeMaxInflight int
HealthInterval time.Duration
FailureCooldown time.Duration
NodeRequestTimeout time.Duration
FailoverEnabled bool
FailoverAttempts int
RequireSameModelDigest bool
RequireEmbeddingModel bool
Model string
EmbeddingModel string
}
type requestMeta struct {
TotalDuration int64 `json:"total_duration"`
LoadDuration int64 `json:"load_duration"`
PromptEvalCount int64 `json:"prompt_eval_count"`
PromptEvalDuration int64 `json:"prompt_eval_duration"`
EvalCount int64 `json:"eval_count"`
EvalDuration int64 `json:"eval_duration"`
}
type poolNode struct {
name string
baseURL string
weight int
sem chan struct{}
inflight atomic.Int64
requests atomic.Uint64
failures atomic.Uint64
mu sync.RWMutex
healthy bool
compatible bool
chatDigest string
embeddingDigest string
lastCheck time.Time
lastSuccess time.Time
cooldownUntil time.Time
consecutiveFailures int
lastError string
averageDurationMS float64
lastRequestDurationMS int64
}
func (n *poolNode) acquire() (int64, bool) {
select {
case n.sem <- struct{}{}:
return n.inflight.Add(1), true
default:
return n.inflight.Load(), false
}
}
func (n *poolNode) release() {
n.inflight.Add(-1)
<-n.sem
}
func (n *poolNode) isEligible(now time.Time, stage string) bool {
n.mu.RLock()
defer n.mu.RUnlock()
if !n.healthy || !n.compatible || now.Before(n.cooldownUntil) {
return false
}
if stage == "embedding" && n.embeddingDigest == "" {
return false
}
return true
}
func (n *poolNode) recordRequest(duration time.Duration, ok bool, retryable bool, err error) {
n.requests.Add(1)
ms := duration.Milliseconds()
if ms < 0 {
ms = 0
}
n.mu.Lock()
n.lastRequestDurationMS = ms
if ok {
if n.averageDurationMS == 0 {
n.averageDurationMS = float64(ms)
} else {
n.averageDurationMS = n.averageDurationMS*0.8 + float64(ms)*0.2
}
n.lastSuccess = time.Now()
n.consecutiveFailures = 0
n.lastError = ""
n.cooldownUntil = time.Time{}
} else {
n.failures.Add(1)
n.consecutiveFailures++
if err != nil {
n.lastError = err.Error()
}
if retryable {
// The caller applies the configured cooldown after releasing the lock.
}
}
n.mu.Unlock()
}
func (n *poolNode) status(maxInflight int) model.OllamaNodeStatus {
n.mu.RLock()
defer n.mu.RUnlock()
now := time.Now()
return model.OllamaNodeStatus{
Name: n.name, URL: n.baseURL, Weight: n.weight,
Healthy: n.healthy, Compatible: n.compatible,
Available: n.healthy && n.compatible && !now.Before(n.cooldownUntil) && n.inflight.Load() < int64(maxInflight),
InFlight: n.inflight.Load(), MaxInFlight: maxInflight,
ChatModelDigest: n.chatDigest, EmbeddingModelDigest: n.embeddingDigest,
LastCheck: n.lastCheck, LastSuccess: n.lastSuccess, CooldownUntil: n.cooldownUntil,
ConsecutiveFailures: n.consecutiveFailures, LastError: n.lastError,
Requests: n.requests.Load(), Failures: n.failures.Load(),
AverageDurationMS: n.averageDurationMS, LastRequestDurationMS: n.lastRequestDurationMS,
}
}
type Pool struct {
cfg PoolConfig
nodes []*poolNode
http *http.Client
rr atomic.Uint64
refresh sync.Mutex
started atomic.Bool
}
func newPool(cfg PoolConfig) (*Pool, error) {
if len(cfg.Nodes) == 0 {
return nil, errors.New("at least one Ollama node is required")
}
if len(cfg.Nodes) > 64 {
return nil, errors.New("at most 64 Ollama nodes are supported")
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, errors.New("Ollama chat model must not be empty")
}
if cfg.RequireEmbeddingModel && strings.TrimSpace(cfg.EmbeddingModel) == "" {
return nil, errors.New("Ollama embedding model must not be empty when it is required")
}
if cfg.NodeMaxInflight <= 0 {
cfg.NodeMaxInflight = 1
}
if cfg.HealthInterval <= 0 {
cfg.HealthInterval = 15 * time.Second
}
if cfg.FailureCooldown < 0 {
cfg.FailureCooldown = 0
}
if cfg.NodeRequestTimeout <= 0 {
cfg.NodeRequestTimeout = 10 * time.Minute
}
if cfg.FailoverAttempts <= 0 {
cfg.FailoverAttempts = len(cfg.Nodes)
}
if cfg.FailoverAttempts > len(cfg.Nodes) {
cfg.FailoverAttempts = len(cfg.Nodes)
}
cfg.RoutingMode = strings.ToLower(strings.TrimSpace(cfg.RoutingMode))
if cfg.RoutingMode == "" {
cfg.RoutingMode = "least_inflight"
}
switch cfg.RoutingMode {
case "least_inflight", "round_robin", "weighted", "fastest_recent":
default:
return nil, fmt.Errorf("unsupported Ollama routing mode %q", cfg.RoutingMode)
}
p := &Pool{cfg: cfg, http: &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: cfg.NodeRequestTimeout,
}}}
seenNames := map[string]struct{}{}
seenURLs := map[string]struct{}{}
for i, c := range cfg.Nodes {
base := strings.TrimRight(strings.TrimSpace(c.URL), "/")
u, err := url.Parse(base)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("invalid Ollama node URL %q", c.URL)
}
if _, ok := seenURLs[base]; ok {
return nil, fmt.Errorf("duplicate Ollama node URL %q", base)
}
seenURLs[base] = struct{}{}
name := strings.TrimSpace(c.Name)
if name == "" {
name = u.Hostname()
if name == "" {
name = fmt.Sprintf("ollama-%d", i+1)
}
}
if _, ok := seenNames[name]; ok {
name = fmt.Sprintf("%s-%d", name, i+1)
}
seenNames[name] = struct{}{}
weight := c.Weight
if weight <= 0 {
weight = 1
}
if weight > 100 {
return nil, fmt.Errorf("Ollama node weight for %q must be between 1 and 100", name)
}
p.nodes = append(p.nodes, &poolNode{name: name, baseURL: base, weight: weight, sem: make(chan struct{}, cfg.NodeMaxInflight)})
}
return p, nil
}
func (p *Pool) Start(ctx context.Context) {
if !p.started.CompareAndSwap(false, true) {
return
}
go func() {
p.refreshAll(ctx)
ticker := time.NewTicker(p.cfg.HealthInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.refreshAll(ctx)
}
}
}()
}
func (p *Pool) Ping(ctx context.Context) error {
p.refreshAll(ctx)
for _, n := range p.nodes {
if n.isEligible(time.Now(), "") {
return nil
}
}
return p.unavailableError()
}
func (p *Pool) NodeStatuses() []model.OllamaNodeStatus {
out := make([]model.OllamaNodeStatus, 0, len(p.nodes))
for _, n := range p.nodes {
out = append(out, n.status(p.cfg.NodeMaxInflight))
}
return out
}
func (p *Pool) refreshAll(ctx context.Context) {
// The first request may arrive while Start is still performing the initial
// health scan. Serialize callers instead of returning early, otherwise a
// healthy pool can briefly look empty during startup.
p.refresh.Lock()
defer p.refresh.Unlock()
type result struct {
node *poolNode
healthy bool
chatDigest string
embeddingDigest string
err error
}
ch := make(chan result, len(p.nodes))
for _, n := range p.nodes {
go func(n *poolNode) {
checkCtx := ctx
cancel := func() {}
timeout := p.cfg.NodeRequestTimeout
if timeout <= 0 || timeout > 10*time.Second {
timeout = 10 * time.Second
}
checkCtx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
chatDigest, embeddingDigest, err := p.checkNode(checkCtx, n)
ch <- result{node: n, healthy: err == nil, chatDigest: chatDigest, embeddingDigest: embeddingDigest, err: err}
}(n)
}
results := make([]result, 0, len(p.nodes))
for range p.nodes {
results = append(results, <-ch)
}
chatDigest, chatConflict := commonDigest(results, func(r result) (string, bool) { return r.chatDigest, r.healthy })
embedDigest, embedConflict := commonDigest(results, func(r result) (string, bool) { return r.embeddingDigest, r.healthy && r.embeddingDigest != "" })
now := time.Now()
for _, r := range results {
n := r.node
n.mu.Lock()
previousHealthy := n.healthy
previousCompatible := n.compatible
previousError := n.lastError
n.lastCheck = now
n.healthy = r.healthy
n.chatDigest = r.chatDigest
n.embeddingDigest = r.embeddingDigest
n.compatible = r.healthy
if r.healthy && p.cfg.RequireSameModelDigest {
switch {
case chatConflict:
n.compatible = false
r.err = fmt.Errorf("chat model digests differ inside the pool; node digest=%s", r.chatDigest)
case chatDigest != "" && r.chatDigest != chatDigest:
n.compatible = false
r.err = fmt.Errorf("chat model digest differs from pool digest: node=%s pool=%s", r.chatDigest, chatDigest)
}
// Even when chat-only nodes are allowed, all nodes that can serve
// embeddings must expose the same embedding model digest.
switch {
case embedConflict:
n.compatible = false
r.err = fmt.Errorf("embedding model digests differ inside the pool; node digest=%s", r.embeddingDigest)
case r.embeddingDigest != "" && embedDigest != "" && r.embeddingDigest != embedDigest:
n.compatible = false
r.err = fmt.Errorf("embedding model digest differs from pool digest: node=%s pool=%s", r.embeddingDigest, embedDigest)
}
}
if r.err != nil {
n.lastError = r.err.Error()
} else {
n.lastError = ""
n.lastSuccess = now
n.consecutiveFailures = 0
}
currentHealthy := n.healthy
currentCompatible := n.compatible
currentError := n.lastError
n.mu.Unlock()
if previousHealthy != currentHealthy || previousCompatible != currentCompatible || previousError != currentError {
if currentHealthy && currentCompatible {
slog.Info("Ollama node available", "node", n.name, "url", n.baseURL, "chat_digest", r.chatDigest, "embedding_digest", r.embeddingDigest)
} else {
slog.Warn("Ollama node unavailable", "node", n.name, "url", n.baseURL, "healthy", currentHealthy, "compatible", currentCompatible, "error", currentError)
}
}
}
}
func commonDigest[T any](results []T, getter func(T) (string, bool)) (string, bool) {
common := ""
for _, r := range results {
digest, include := getter(r)
digest = strings.TrimSpace(digest)
if !include || digest == "" {
continue
}
if common == "" {
common = digest
continue
}
if digest != common {
return "", true
}
}
return common, false
}
func (p *Pool) checkNode(ctx context.Context, n *poolNode) (string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, n.baseURL+"/api/tags", nil)
if err != nil {
return "", "", err
}
resp, err := p.http.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", "", err
}
if resp.StatusCode/100 != 2 {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var tags struct {
Models []struct {
Name string `json:"name"`
Model string `json:"model"`
Digest string `json:"digest"`
} `json:"models"`
}
if err := json.Unmarshal(body, &tags); err != nil {
return "", "", fmt.Errorf("decode /api/tags: %w", err)
}
find := func(wanted string) string {
wanted = strings.TrimSpace(wanted)
for _, m := range tags.Models {
if modelNameMatches(wanted, m.Name) || modelNameMatches(wanted, m.Model) {
return strings.TrimSpace(m.Digest)
}
}
return ""
}
chat := find(p.cfg.Model)
if chat == "" {
return "", "", fmt.Errorf("model %q is not installed", p.cfg.Model)
}
embed := ""
if strings.TrimSpace(p.cfg.EmbeddingModel) != "" {
embed = find(p.cfg.EmbeddingModel)
if p.cfg.RequireEmbeddingModel && embed == "" {
return "", "", fmt.Errorf("embedding model %q is not installed", p.cfg.EmbeddingModel)
}
}
return chat, embed, nil
}
func modelNameMatches(wanted, got string) bool {
wanted = strings.TrimSpace(wanted)
got = strings.TrimSpace(got)
if wanted == got {
return true
}
if !strings.Contains(wanted, ":") && strings.TrimSuffix(got, ":latest") == wanted {
return true
}
if !strings.Contains(got, ":") && strings.TrimSuffix(wanted, ":latest") == got {
return true
}
return false
}
func (p *Pool) post(ctx context.Context, path string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
if !p.anyKnownHealthy() {
p.refreshAll(ctx)
}
attemptLimit := 1
if p.cfg.FailoverEnabled {
attemptLimit = p.cfg.FailoverAttempts
if attemptLimit <= 0 || attemptLimit > len(p.nodes) {
attemptLimit = len(p.nodes)
}
}
attempted := map[string]struct{}{}
var errs []error
for attempt := 1; attempt <= attemptLimit; attempt++ {
n, inflight, selectErr := p.selectNode(ctx, attempted, requestStage(ctx))
if selectErr != nil {
errs = append(errs, selectErr)
break
}
attempted[n.name] = struct{}{}
started := time.Now()
stage := requestStage(ctx)
status, raw, reqErr := p.doPost(ctx, n, path, body)
if reqErr == nil && len(raw) > 0 {
var envelope struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &envelope) == nil && strings.TrimSpace(envelope.Error) != "" {
reqErr = fmt.Errorf("Ollama %s returned an error from %s: %s", path, n.name, strings.TrimSpace(envelope.Error))
}
}
if reqErr == nil && out != nil {
if len(raw) == 0 {
reqErr = errors.New("empty Ollama response")
} else if decodeErr := json.Unmarshal(raw, out); decodeErr != nil {
reqErr = fmt.Errorf("decode Ollama %s response from %s: %w", path, n.name, decodeErr)
}
}
duration := time.Since(started)
n.release()
retryable := isRetryable(reqErr, status)
// A 2xx response that cannot be decoded is safe to fail over because no
// application decision was accepted from this node.
if reqErr != nil && status/100 == 2 {
retryable = true
}
ok := reqErr == nil
n.recordRequest(duration, ok, retryable, reqErr)
if !ok && retryable && p.cfg.FailureCooldown > 0 {
n.mu.Lock()
n.cooldownUntil = time.Now().Add(p.cfg.FailureCooldown)
n.mu.Unlock()
}
meta := requestMeta{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &meta)
}
recordTraceAttempt(ctx, model.OllamaRequestAttempt{
Attempt: attempt, Stage: stage, Path: path, NodeName: n.name, NodeURL: n.baseURL,
ModelDigest: n.digestForStage(stage), StartedAt: started, DurationMS: duration.Milliseconds(),
InflightAtStart: inflight, HTTPStatus: status, Outcome: outcomeText(reqErr), Retryable: retryable,
Error: errorText(reqErr), TotalDurationNS: meta.TotalDuration, LoadDurationNS: meta.LoadDuration,
PromptEvalCount: meta.PromptEvalCount, PromptEvalDuration: meta.PromptEvalDuration,
EvalCount: meta.EvalCount, EvalDuration: meta.EvalDuration,
})
if reqErr == nil {
markTraceSuccess(ctx, n.name, n.baseURL)
return nil
}
errs = append(errs, fmt.Errorf("%s: %w", n.name, reqErr))
if retryable && p.cfg.FailoverEnabled && attempt < attemptLimit && ctx.Err() == nil {
slog.Warn("Ollama request failed; trying another node", "stage", stage, "path", path, "failed_node", n.name, "attempt", attempt, "max_attempts", attemptLimit, "error", reqErr)
}
if !retryable || !p.cfg.FailoverEnabled || attempt == attemptLimit || ctx.Err() != nil {
break
}
}
if len(errs) == 0 {
return p.unavailableError()
}
return errors.Join(errs...)
}
func (n *poolNode) digestForStage(stage string) string {
n.mu.RLock()
defer n.mu.RUnlock()
if stage == "embedding" {
return n.embeddingDigest
}
return n.chatDigest
}
func (p *Pool) doPost(ctx context.Context, n *poolNode, path string, body []byte) (int, []byte, error) {
reqCtx := ctx
cancel := func() {}
if p.cfg.NodeRequestTimeout > 0 {
reqCtx, cancel = context.WithTimeout(ctx, p.cfg.NodeRequestTimeout)
}
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, n.baseURL+path, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.http.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return resp.StatusCode, nil, err
}
if resp.StatusCode/100 != 2 {
return resp.StatusCode, raw, fmt.Errorf("Ollama %s failed: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return resp.StatusCode, raw, nil
}
func (p *Pool) selectNode(ctx context.Context, excluded map[string]struct{}, stage string) (*poolNode, int64, error) {
for {
nodes := p.orderedCandidates(excluded, stage)
if len(nodes) == 0 {
return nil, 0, p.unavailableError()
}
for _, n := range nodes {
if inflight, ok := n.acquire(); ok {
return n, inflight, nil
}
}
select {
case <-ctx.Done():
return nil, 0, ctx.Err()
case <-time.After(15 * time.Millisecond):
}
}
}
func (p *Pool) orderedCandidates(excluded map[string]struct{}, stage string) []*poolNode {
now := time.Now()
out := make([]*poolNode, 0, len(p.nodes))
for _, n := range p.nodes {
if _, skip := excluded[n.name]; skip {
continue
}
if n.isEligible(now, stage) {
out = append(out, n)
}
}
if len(out) <= 1 {
return out
}
switch p.cfg.RoutingMode {
case "round_robin":
start := int(p.rr.Add(1)-1) % len(out)
rotated := append([]*poolNode(nil), out[start:]...)
rotated = append(rotated, out[:start]...)
return rotated
case "fastest_recent":
sort.SliceStable(out, func(i, j int) bool {
ai, aj := out[i].status(p.cfg.NodeMaxInflight), out[j].status(p.cfg.NodeMaxInflight)
// Probe nodes without measurements before permanently preferring a
// known node. This prevents new/recovered nodes from starving.
if ai.AverageDurationMS == 0 && aj.AverageDurationMS != 0 {
return true
}
if aj.AverageDurationMS == 0 && ai.AverageDurationMS != 0 {
return false
}
if ai.AverageDurationMS == aj.AverageDurationMS {
if ai.InFlight == aj.InFlight {
return ai.Requests < aj.Requests
}
return ai.InFlight < aj.InFlight
}
return ai.AverageDurationMS < aj.AverageDurationMS
})
case "weighted":
sort.SliceStable(out, func(i, j int) bool {
// Weighted least-request scheduling also works for serial traffic;
// using only current inflight values would permanently select the
// highest-weight node whenever requests do not overlap.
si := float64(out[i].requests.Load()+uint64(out[i].inflight.Load())+1) / float64(maxInt(out[i].weight, 1))
sj := float64(out[j].requests.Load()+uint64(out[j].inflight.Load())+1) / float64(maxInt(out[j].weight, 1))
if math.Abs(si-sj) < 1e-9 {
return out[i].name < out[j].name
}
return si < sj
})
default: // least_inflight
sort.SliceStable(out, func(i, j int) bool {
ii, ij := out[i].inflight.Load(), out[j].inflight.Load()
if ii == ij {
ri, rj := out[i].requests.Load(), out[j].requests.Load()
if ri != rj {
return ri < rj
}
si, sj := out[i].status(p.cfg.NodeMaxInflight), out[j].status(p.cfg.NodeMaxInflight)
if si.AverageDurationMS != sj.AverageDurationMS && si.AverageDurationMS > 0 && sj.AverageDurationMS > 0 {
return si.AverageDurationMS < sj.AverageDurationMS
}
return out[i].name < out[j].name
}
return ii < ij
})
}
return out
}
func (p *Pool) anyKnownHealthy() bool {
for _, n := range p.nodes {
n.mu.RLock()
known := !n.lastCheck.IsZero()
healthy := n.healthy && n.compatible
n.mu.RUnlock()
if known && healthy {
return true
}
}
return false
}
func (p *Pool) unavailableError() error {
statuses := p.NodeStatuses()
parts := make([]string, 0, len(statuses))
for _, s := range statuses {
detail := s.LastError
if detail == "" {
detail = "not available"
}
parts = append(parts, fmt.Sprintf("%s: %s", s.Name, detail))
}
return fmt.Errorf("no compatible Ollama node available (%s)", strings.Join(parts, "; "))
}
func isRetryable(err error, status int) bool {
if err == nil {
return false
}
if status == 0 || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests {
return true
}
return status == http.StatusBadGateway || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout || status >= 500
}
func outcomeText(err error) string {
if err == nil {
return "success"
}
return "error"
}
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// Trace is a concurrency-safe collector attached to one logical analysis context.
type Trace struct {
mu sync.Mutex
data model.OllamaProviderTrace
}
type traceKey struct{}
type stageKey struct{}
func WithTrace(ctx context.Context, routingMode string) (context.Context, *Trace) {
t := &Trace{data: model.OllamaProviderTrace{Provider: providerName, RoutingMode: routingMode}}
return context.WithValue(ctx, traceKey{}, t), t
}
func withStage(ctx context.Context, stage string) context.Context {
return context.WithValue(ctx, stageKey{}, strings.TrimSpace(stage))
}
func requestStage(ctx context.Context) string {
v, _ := ctx.Value(stageKey{}).(string)
return v
}
func recordTraceAttempt(ctx context.Context, a model.OllamaRequestAttempt) {
t, _ := ctx.Value(traceKey{}).(*Trace)
if t == nil {
return
}
t.mu.Lock()
a.Attempt = len(t.data.Attempts) + 1
if len(t.data.Attempts) > 0 {
previous := t.data.Attempts[len(t.data.Attempts)-1]
if previous.NodeName != a.NodeName && previous.Retryable && previous.Outcome == "error" {
t.data.FailoverUsed = true
}
}
t.data.Attempts = append(t.data.Attempts, a)
t.data.AttemptCount = len(t.data.Attempts)
t.mu.Unlock()
}
func markTraceSuccess(ctx context.Context, node, nodeURL string) {
t, _ := ctx.Value(traceKey{}).(*Trace)
if t == nil {
return
}
t.mu.Lock()
t.data.SelectedNode = node
t.data.SelectedURL = nodeURL
t.mu.Unlock()
}
func (t *Trace) Snapshot() model.OllamaProviderTrace {
if t == nil {
return model.OllamaProviderTrace{}
}
t.mu.Lock()
defer t.mu.Unlock()
out := t.data
out.Attempts = append([]model.OllamaRequestAttempt(nil), t.data.Attempts...)
return out
}