Skip to content

Commit 48b08c0

Browse files
committed
feat(distributed): stamp node ID in router and wire middleware to inference routes
ModelRouterAdapter.Route stamps the picked node ID into the per-request holder via distributedhdr.Stamp(ctx, result.Node.ID) right after replica selection. Wire ExposeNodeHeader middleware to: - OpenAI chat/completion/embeddings + audio transcriptions/speech + image generations/inpainting - Anthropic /v1/messages - Ollama /api/chat, /api/generate, /api/embed, /api/embeddings - Jina /v1/rerank - LocalAI /v1/vad The middleware's wrapper reads the holder on first byte and sets the X-LocalAI-Node response header before delegating to the underlying writer. Per-request scope means no race under concurrent multi-replica routing. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent a1c2e01 commit 48b08c0

6 files changed

Lines changed: 38 additions & 0 deletions

File tree

core/http/routes/anthropic.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/mudler/LocalAI/core/services/routing/pii"
1717
"github.com/mudler/LocalAI/core/services/routing/piiadapter"
1818
"github.com/mudler/LocalAI/core/services/routing/router"
19+
"github.com/mudler/LocalAI/pkg/distributedhdr"
1920
"github.com/mudler/xlog"
2021
)
2122

@@ -40,6 +41,7 @@ func RegisterAnthropicRoutes(app *echo.Echo,
4041
)
4142

4243
messagesMiddleware := []echo.MiddlewareFunc{
44+
middleware.ExposeNodeHeader(application.ApplicationConfig()),
4345
middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser()),
4446
middleware.TraceMiddleware(application),
4547
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
@@ -111,6 +113,7 @@ func setAnthropicRequestContext(appConfig *config.ApplicationConfig) echo.Middle
111113

112114
// Add the correlation ID to the new context
113115
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
116+
ctxWithCorrelationID = distributedhdr.Inherit(ctxWithCorrelationID, reqCtx)
114117

115118
input.Context = ctxWithCorrelationID
116119
input.Cancel = cancel

core/http/routes/jina.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func RegisterJINARoutes(app *echo.Echo,
2020
rerankHandler := jina.JINARerankEndpoint(cl, ml, appConfig)
2121
app.POST("/v1/rerank",
2222
rerankHandler,
23+
middleware.ExposeNodeHeader(appConfig),
2324
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_RERANK)),
2425
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.JINARerankRequest) }))
2526
}

core/http/routes/localai.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,15 @@ func RegisterLocalAIRoutes(router *echo.Echo,
165165
middleware.TraceMiddleware(app))
166166

167167
vadHandler := localai.VADEndpoint(cl, ml, appConfig)
168+
vadNodeHeader := middleware.ExposeNodeHeader(appConfig)
168169
router.POST("/vad",
169170
vadHandler,
171+
vadNodeHeader,
170172
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_VAD)),
171173
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.VADRequest) }))
172174
router.POST("/v1/vad",
173175
vadHandler,
176+
vadNodeHeader,
174177
requestExtractor.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_VAD)),
175178
requestExtractor.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.VADRequest) }))
176179

core/http/routes/ollama.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/mudler/LocalAI/core/http/endpoints/ollama"
1111
"github.com/mudler/LocalAI/core/http/middleware"
1212
"github.com/mudler/LocalAI/core/schema"
13+
"github.com/mudler/LocalAI/pkg/distributedhdr"
1314
)
1415

1516
func RegisterOllamaRoutes(app *echo.Echo,
@@ -18,6 +19,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
1819

1920
traceMiddleware := middleware.TraceMiddleware(application)
2021
usageMiddleware := middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser())
22+
nodeHeaderMiddleware := middleware.ExposeNodeHeader(application.ApplicationConfig())
2123

2224
// Chat endpoint: POST /api/chat
2325
chatHandler := ollama.ChatEndpoint(
@@ -27,6 +29,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
2729
application.ApplicationConfig(),
2830
)
2931
chatMiddleware := []echo.MiddlewareFunc{
32+
nodeHeaderMiddleware,
3033
usageMiddleware,
3134
traceMiddleware,
3235
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
@@ -43,6 +46,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
4346
application.ApplicationConfig(),
4447
)
4548
generateMiddleware := []echo.MiddlewareFunc{
49+
nodeHeaderMiddleware,
4650
usageMiddleware,
4751
traceMiddleware,
4852
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
@@ -58,6 +62,7 @@ func RegisterOllamaRoutes(app *echo.Echo,
5862
application.ApplicationConfig(),
5963
)
6064
embedMiddleware := []echo.MiddlewareFunc{
65+
nodeHeaderMiddleware,
6166
usageMiddleware,
6267
traceMiddleware,
6368
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
@@ -108,6 +113,7 @@ func setOllamaChatRequestContext(appConfig *config.ApplicationConfig) echo.Middl
108113
}()
109114

110115
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
116+
ctxWithCorrelationID = distributedhdr.Inherit(ctxWithCorrelationID, reqCtx)
111117
input.Context = ctxWithCorrelationID
112118
input.Cancel = cancel
113119

@@ -149,6 +155,7 @@ func setOllamaGenerateRequestContext(appConfig *config.ApplicationConfig) echo.M
149155
}()
150156

151157
ctxWithCorrelationID := context.WithValue(c1, middleware.CorrelationIDKey, correlationID)
158+
ctxWithCorrelationID = distributedhdr.Inherit(ctxWithCorrelationID, reqCtx)
152159
input.Ctx = ctxWithCorrelationID
153160
input.Cancel = cancel
154161

core/http/routes/openai.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ func RegisterOpenAIRoutes(app *echo.Echo,
2020
// openAI compatible API endpoint
2121
traceMiddleware := middleware.TraceMiddleware(application)
2222
usageMiddleware := middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser())
23+
// X-LocalAI-Node attribution middleware: wraps the response writer and
24+
// stamps the header on first write when --expose-node-header is on. No-op
25+
// otherwise. Applied to every inference path that routes through
26+
// ml.Load (chat, completion, embeddings, audio transcriptions/speech,
27+
// image generation/inpainting) so distributed-mode operators can observe
28+
// which worker served each request.
29+
nodeHeaderMiddleware := middleware.ExposeNodeHeader(application.ApplicationConfig())
2330

2431
// realtime
2532
// TODO: Modify/disable the API key middleware for this endpoint to allow ephemeral keys created by sessions
@@ -37,6 +44,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
3744
// chat
3845
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig(), natsClient, application.LocalAIAssistant(), application.PIIRedactor(), application.PIIEvents())
3946
chatMiddleware := []echo.MiddlewareFunc{
47+
nodeHeaderMiddleware,
4048
usageMiddleware,
4149
traceMiddleware,
4250
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
@@ -110,6 +118,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
110118
// completion
111119
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig(), application.PIIRedactor(), application.PIIEvents())
112120
completionMiddleware := []echo.MiddlewareFunc{
121+
nodeHeaderMiddleware,
113122
usageMiddleware,
114123
traceMiddleware,
115124
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_COMPLETION)),
@@ -131,6 +140,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
131140
// embeddings
132141
embeddingHandler := openai.EmbeddingsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
133142
embeddingMiddleware := []echo.MiddlewareFunc{
143+
nodeHeaderMiddleware,
134144
usageMiddleware,
135145
traceMiddleware,
136146
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
@@ -151,6 +161,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
151161

152162
audioHandler := openai.TranscriptEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
153163
audioMiddleware := []echo.MiddlewareFunc{
164+
nodeHeaderMiddleware,
154165
traceMiddleware,
155166
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_TRANSCRIPT)),
156167
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
@@ -186,6 +197,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
186197

187198
audioSpeechHandler := localai.TTSEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
188199
audioSpeechMiddleware := []echo.MiddlewareFunc{
200+
nodeHeaderMiddleware,
189201
traceMiddleware,
190202
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_TTS)),
191203
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.TTSRequest) }),
@@ -197,6 +209,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
197209
// images
198210
imageHandler := openai.ImageEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
199211
imageMiddleware := []echo.MiddlewareFunc{
212+
nodeHeaderMiddleware,
200213
traceMiddleware,
201214
// Default: use the first available image generation model
202215
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_IMAGE)),

core/services/nodes/model_router.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"sync"
77

8+
"github.com/mudler/LocalAI/pkg/distributedhdr"
89
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
910
"github.com/mudler/LocalAI/pkg/model"
1011
"github.com/mudler/xlog"
@@ -69,6 +70,16 @@ func (a *ModelRouterAdapter) Route(ctx context.Context, backend, modelID, modelN
6970
// the ModelLoader returns this model on subsequent requests.
7071
m := model.NewModelWithClient(modelID, result.Node.Address, result.Client)
7172

73+
// Publish the picked node ID into the per-request holder attached to
74+
// ctx (by middleware.ExposeNodeHeader). No-op when the holder is
75+
// absent - e.g. health-check probes, watchdog reloads or any path that
76+
// did not flow through the HTTP middleware. The response writer
77+
// wrapper reads this on the first byte to set X-LocalAI-Node, so
78+
// attribution is exact for the request that triggered THIS Route call,
79+
// even when several requests for the same modelID are being routed
80+
// concurrently to different replicas.
81+
distributedhdr.Stamp(ctx, result.Node.ID)
82+
7283
xlog.Info("Model routed to remote node", "model", modelName, "node", result.Node.Name, "address", result.Node.Address)
7384
return m, nil
7485
}

0 commit comments

Comments
 (0)