Files
LocalAI/tests/e2e/distributed/distributed_full_flow_test.go
T
mudler-agentandEttore Di Giacinto e4fa051ee3 refactor(distributed): put the NATS-only paths behind interfaces (#12395)
* feat(messaging): add shared subject rules

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(messaging): cover BroadcastRoots, ControlRoots and SubjectRoot

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add Broadcaster and enforce subject rules in every carrier

Broadcaster is the fan-out half of MessagingClient. The NATS client and
the in-memory FakeBus now refuse a subject outside the served roots and any
wildcard other than a whole single token, and FakeBus shares MatchSubject
instead of its own copy. FakeBus Unsubscribe now removes its own
subscription instead of the first one with the same subject.

A shared conformance suite in messagingtest runs against both carriers.
The distributed e2e specs that used invented test.* subjects, and the one
that subscribed with a > filter, now use subjects from subjects.go.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: depend on Broadcaster where only publish and subscribe are used

Narrowed to messaging.Broadcaster: nodes/staging_progress.go,
nodes/install_progress_publisher.go, galleryop/operation.go,
galleryop/service.go, agentpool/user_services.go, agentpool/agent_jobs.go,
openresponses/store.go, openresponses/sync.go, syncstate/syncstate.go,
finetune/service.go, quantization/service.go and
failover/distsync/distsync.go. SubscribeJSON now takes a Broadcaster
because it only calls Subscribe, which lets the narrowed consumers use it.

Stayed wide: worker/supervisor.go, because its client field also serves
the SubscribeReply handlers in worker/lifecycle.go. The request/reply,
queue and wiring files (nodes/unloader.go, nodes/file_stager_s3.go,
jobs/dispatcher.go, agents/dispatcher.go, agents/events.go,
worker/file_staging.go, cli/agent_worker.go, http/app.go) are unchanged by
design.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): name the no-route condition and confine the carrier error

Consumers matched nats.ErrNoResponders, which names an absence, to demote a node. They now match ErrNoRoute, the control path maps the carrier's failure onto it, and timeouts and worker refusals are pinned as not being no-route.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(nodes): state which FileStager implementations return ErrNoRoute

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): build backend clients through one node-aware seam

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: describe the distributed transport seams

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: correct comments that overclaim after the seams refactor

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(agent-worker): refuse an unserved LOCALAI_AGENT_SUBJECT at startup

The messaging client now refuses a subject whose root no carrier serves.
An agent worker started with a custom LOCALAI_AGENT_SUBJECT such as
tenant-a.agent.execute used to start and then wait on a subject the
frontend never publishes to. After the subject rules landed it exited
at subscribe time with an error that did not name the setting.

Behaviour change: the worker now checks LOCALAI_AGENT_SUBJECT before it
registers or connects, and exits with an error that names the variable
and says to use a served subject under the agent root, for example
agent.execute. The served roots are not widened: a custom root was
never delivered by the frontend, and a wider set would reopen the
drift the subject rules exist to close. The flag help and the agent
worker docs state the constraint.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(nodes): pin the reactions to ErrNoRoute

Three callers react to ErrNoRoute and had no spec: the reconciler's
upgrade drain falls back to the legacy forced install, the reconciler
marks the node unhealthy when a pending op has no route, and the
backend-op fan-out marks the node unhealthy. Each spec drives the real
caller with a scripted no-responders reply and reads the result from
the registry or the recorded requests.

A fourth spec pins the other side: a pending op that times out leaves
the node healthy and only counts the attempt, so mapping timeouts onto
ErrNoRoute would fail here.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(messaging): pin client subject checks and fail the carrier suite in CI

Add specs that call Publish, Request, Subscribe, QueueSubscribe,
SubscribeReply and QueueSubscribeReply on a client with no connection.
Each call must return ErrUnservedSubject for bogus.thing and
ErrUnsupportedWildcard for jobs.>. This proves that the subject check
runs before the connection is used, and needs no server.

The NATS conformance suite is the only check that runs the subject rules
against a real carrier. Before this change it skipped without output
when Docker was missing. Now it fails when CI is set, so a Linux runner
without Docker cannot hide it. It still skips on local runs and on macOS
CI, which has no Docker.

Add SubjectNodeBackendInstallProgress to the list of constructors that
must build served subjects, and ask contributors to extend the list.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: state what ErrNoRoute may change, and group the distributed guides

The seams note said MarkUnhealthy was the only state change allowed
on ErrNoRoute. A pending backend op still records the failed attempt,
counts toward the reconciler's retry limit and is dead-lettered after
the maximum attempts. The note now says that MarkUnhealthy is the only
change to the node's own state, and that the per-op accounting is not
a verdict about the node.

The note also documents that the NATS conformance run fails under CI
when Docker is missing. The distributed-seams row moves next to the
distributed-state row in the topics table. The liveness ping spec
header now says no route is a reason to skip the worker, not proof
that the worker is gone.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): give the backend client factory the node id

Mechanical: the method gains a nodeID parameter and the eight test fakes are updated. No behaviour change.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): drop the optional node-aware factory

The node id is now in the main method, so the optional interface and its helper had no behaviour of their own.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): dial backend probes through the client factory

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(nodes): dial workers' file servers through a per-node dialer

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(http): proxy backend logs through the per-node worker dialer

The admin backend-logs proxy (list, lines and the WebSocket stream) now reaches a worker through the same per-node dialer as the HTTP file stager, so every frontend-to-worker dial goes through one seam. The shared direct dialer keeps alive for 15s where the proxy used 30s. Harmless for requests bounded at 15s.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(http): keep the backend-logs proxy independent of the admin connection

The proxy request had no context before the dialer change and is bounded only by its 15s timeout. Keep it that way.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: move the worker control payloads to workerctl

Mechanical move of the request and reply structs, the install progress event and the file payloads out of messaging. The verbs no longer belong to one carrier. No alias is left behind.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): serve the lifecycle verbs through a controlServer

The worker registers one handler per verb and a NATS server maps each verb to its subject. Registration errors now name the verb. node.stop is served with SubscribeReply, which is identical on the wire because the handler never replies.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): report install progress through the control sink

Install and upgrade now emit download progress through the sink the control server hands them. The debounce and the terminal flush stay in the handler path, built over that sink by the new nodes.NewDebouncedInstallProgressSink, which replaces NewDebouncedInstallProgressPublisher. The subject and payload on the wire are unchanged. The supervisor no longer holds the bus, and installFn and upgradeFn let specs drive both verbs without a gallery.

The malformed-request log lines are restored for install, upgrade, backend.delete, model.unload, model.stop and model.delete, with the reply bytes unchanged. The signal adapter is renamed noReply, which also lets worker.go import os/signal without an alias again.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(worker): serve the file-staging verbs through a controlServer

An empty list-dir answer is now {} rather than {"files":null}, because the typed reply omits an empty Files slice. The frontend decodes both to a nil slice in nodes/file_stager_s3.go.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add WorkQueue and the NATS producer

This is the producer side of the competing-consumer seam. The work kinds map one to one to today's subjects and queue groups: task to jobs.new and mcp-ci to jobs.mcp-ci.new (both in group workers), agent-run to agent.execute (group agent-workers). Enqueue publishes the payload as Publish does today, with one JSON marshal. FakeBus now records queue groups and keeps reply handlers so later specs can pin and drive them.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(messaging): add the NATS WorkConsumer

An in-flight limit of one runs the handler inline on the delivery goroutine, as the MCP CI consumer does today. Any other limit spawns per delivery, as the agent consumer does. Queue groups are unchanged.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: publish queued work through WorkQueue

The job dispatcher, the agent pool and the agent scheduler enqueue through messaging.WorkQueue; the NATS implementation publishes to the same subjects as before. DistributedServices builds the queue next to the NATS client and hands it to the dispatcher and the agent pool, whose distributed mode switch now reads a non-nil WorkQueue. The unused AgentPoolService.SetNATSClient is removed.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: consume queued work through WorkConsumer

The agent dispatcher and the MCP CI consumer register through messaging.WorkConsumer. The NATS implementation keeps the inline one-at-a-time model for MCP CI and the per-delivery model for agent runs. handleMCPCIJob reports on the events publisher the carrier hands it instead of a captured client.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: delete the consumers nothing in production reached

jobs.new has a producer and no production consumer, and the agent dispatcher's Dispatch was only called from tests. Publishing jobs.new is unchanged.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(mcp): send MCP requests to agent workers through AgentControl

Timeouts still honour only the deadline, not cancellation, exactly as today. The NATS no-responders error maps to ErrNoRoute and a timeout does not.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(agent-worker): serve MCP requests and backend.stop through agentRPCServer

The agent worker's MCP tool and discovery reply subscriptions and its backend stop listener move behind an unexported agentRPCServer interface, served on NATS by nodes.NATSAgentRPCServer. The handlers become typed mcp.ToolHandler and mcp.DiscoveryHandler values that answer every failure with a reply carrying Error.

Queue group (agent-workers), inline execution on the delivery goroutine, the background handler context, the unmarshal error reply texts and the reply-less backend stop subscription are unchanged. The backend stop handler takes the decoded backend name, so it can still close that backend's MCP sessions.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(messaging): remove helpers that only tests used

BroadcastRoots, ControlRoots and SubjectRoot had no production caller. The roots spec now asserts every served root through ValidateSubject instead. MatchSubject moves back into the test support package, the only place that used it, with its table. NATSAgentRPCServer drops the subscription list it stored and never read, and NewNATSAgentRPCServer gets a doc comment.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(mcp): round trip the agent RPC server over a real NATS server

One spec sends a tool request and a discovery request through NATSAgentControl to NATSAgentRPCServer and checks that the handlers see the decoded requests and the replies come back. It also puts an undecodable body on the tool subject and checks the server answers with an unmarshal error instead of leaving the requester to time out.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: describe the distributed transport seams

The developer note now lists the final seams: fan-out, queues, both halves of the control verbs and of agent RPC, and the dial. It records the open items a second carrier has to handle.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test: pin the in-flight limit each queue consumer asks for

The work queue specs pin what Consume does for a given limit, but nothing
pinned which limit each production consumer passes. Changing the agent
worker's MCP CI limit from 1 to 0 would have let MCP CI jobs run
concurrently on each worker with every test green.

Move the MCP CI Consume call into startMCPCIConsumer with the same wiring
and pin that it asks for (WorkMCPCI, 1). Pin that NATSDispatcher.Start asks
for (WorkAgentRun, maxConcurrent) for several limits.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor: remove helpers the branch left without a caller

SubjectJobCancelWildcard lost its last subscriber when the frontend
stopped listening on jobs.*.cancel; the NATS permissions and conformance
suite spell the subject out, so nothing reads the constant.

decodeBackendStopRequest returned a stopAll flag that production dropped
and only a test read. decodeBackendStop is now the single decoder with the
same semantics: an empty body is stop-all, an empty Backend is stop-all,
malformed JSON is an error. stopBackends still derives stop-all from
Backend, so no reply changes.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(messaging): keep an explicitly empty agent queue a plain subscription

Before the work queue seam the agent worker passed LOCALAI_AGENT_QUEUE
straight to QueueSubscribe, so an explicitly empty value made a plain
subscription and every agent worker ran every agent run.
WithAgentRunRoute replaced an empty queue with agent-workers, which
silently changed that.

Keep the queue as given once the option is applied. An empty subject still
falls back to agent.execute, since it never had a meaning of its own. The
flag default stays agent-workers, so only an explicitly empty value
reaches this.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: correct comments and record the PR B notes

Fix the recordingFactory comment (it also records the parallel flag),
document that a negative maxInFlight is unbounded and that Unsubscribe from
a handler deadlocks, and say a permanently undecodable payload returns nil.

Record controlHandler's undecodable return as a kept exception, and add the
second carrier notes to the developer note: the reconciler has no
ClientFactory option, the logs proxy honours HTTP_PROXY, verbs one carrier
serves need an opt-out, terminal replies come from the result event, and
agent runs publish through the NATS-bound EventBridge, which is not an
additive change.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-02 23:58:41 +02:00

1003 lines
35 KiB
Go

package distributed_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/workerctl"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/nats-io/nats.go"
"google.golang.org/grpc"
pgdriver "gorm.io/driver/postgres"
gormDB "gorm.io/gorm"
"gorm.io/gorm/logger"
)
// testLLM is a minimal AIModel implementation for testing.
// Override methods to write output to Dst so we can test the full
// FileStagingClient round-trip (upload inputs + download outputs).
type testLLM struct {
base.Base
loaded bool
lastModel string
// dstOutput is the content written to any Dst path by output-producing methods.
dstOutput []byte
// lastSrc records the last Src/input path seen (for verifying staging rewrote it).
lastSrc string
// lastVideoAudio records the staged Audio path from GenerateVideo.
lastVideoAudio string
// lastAudioDst records the Dst field from AudioTranscription (it's an input, not output).
lastAudioDst string
// lastTTSModel records the Model field from TTS requests (for verifying path rewriting).
lastTTSModel string
}
func (t *testLLM) Load(opts *pb.ModelOptions) error {
t.loaded = true
t.lastModel = opts.ModelFile
return nil
}
func (t *testLLM) Predict(opts *pb.PredictOptions) (string, error) {
if !t.loaded {
return "", fmt.Errorf("model not loaded")
}
return "test response from remote node", nil
}
func (t *testLLM) GenerateImage(req *pb.GenerateImageRequest) error {
t.lastSrc = req.Src
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) GenerateVideo(req *pb.GenerateVideoRequest) error {
t.lastSrc = req.StartImage
t.lastVideoAudio = req.Audio
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) TTS(req *pb.TTSRequest) error {
t.lastTTSModel = req.Model
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) SoundGeneration(req *pb.SoundGenerationRequest) error {
if req.Src != nil {
t.lastSrc = *req.Src
}
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) AudioTranscription(_ context.Context, req *pb.TranscriptRequest) (pb.TranscriptResult, error) {
t.lastAudioDst = req.Dst
return pb.TranscriptResult{Text: "transcribed text"}, nil
}
// startTestGRPCServer starts a real gRPC backend server on a free port
// and returns the address and cleanup function.
func startTestGRPCServer(llm grpcPkg.AIModel) (string, func(), error) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
addr := lis.Addr().String()
s := grpc.NewServer(
grpc.MaxRecvMsgSize(50*1024*1024),
grpc.MaxSendMsgSize(50*1024*1024),
)
pb.RegisterBackendServer(s, grpcPkg.NewBackendServer(llm))
go func() {
defer GinkgoRecover()
_ = s.Serve(lis)
}()
cleanup := func() {
s.GracefulStop()
}
return addr, cleanup, nil
}
// startTestHTTPFileServer starts a test HTTP file transfer server (mirroring serve_backend_http.go)
// on a free port and returns the address and cleanup function.
func startTestHTTPFileServer(stagingDir string) (string, func(), error) {
if err := os.MkdirAll(stagingDir, 0750); err != nil {
return "", nil, err
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/files/", func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Path[len("/v1/files/"):]
switch r.Method {
case http.MethodPut:
safeName := filepath.Base(key)
dstPath := filepath.Join(stagingDir, safeName)
f, err := os.Create(dstPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(f, r.Body)
f.Close()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"local_path":%q}`, dstPath)
case http.MethodGet:
safeName := filepath.Base(key)
srcPath := filepath.Join(stagingDir, safeName)
if _, statErr := os.Stat(srcPath); os.IsNotExist(statErr) {
// AllocRemoteTemp creates files under stagingDir/tmp/
srcPath = filepath.Join(stagingDir, "tmp", safeName)
}
f, err := os.Open(srcPath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
io.Copy(w, f)
case http.MethodPost:
if key == "temp" {
tmpDir := filepath.Join(stagingDir, "tmp")
os.MkdirAll(tmpDir, 0750)
f, err := os.CreateTemp(tmpDir, "output-*")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
localPath := f.Name()
f.Close()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"local_path":%q}`, localPath)
} else {
http.Error(w, "not found", http.StatusNotFound)
}
}
})
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
httpAddr := lis.Addr().String()
srv := &http.Server{Handler: mux}
go srv.Serve(lis)
cleanup := func() {
srv.Close()
}
return httpAddr, cleanup, nil
}
var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func() {
var (
infra *TestInfra
cancel context.CancelFunc
ctx context.Context
db *gormDB.DB
registry *nodes.NodeRegistry
)
BeforeEach(func() {
infra = SetupInfra("localai_fullflow_test")
ctx, cancel = context.WithTimeout(infra.Ctx, 2*time.Minute)
var err error
db, err = gormDB.Open(pgdriver.Open(infra.PGURL), &gormDB.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
Expect(err).ToNot(HaveOccurred())
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
cancel()
})
// newTestSmartRouter creates a SmartRouter with NATS wired up and a mock
// backend.install handler that always replies success for all registered nodes.
newTestSmartRouter := func(reg *nodes.NodeRegistry, extraOpts ...nodes.SmartRouterOptions) *nodes.SmartRouter {
unloader := nodes.NewRemoteUnloaderAdapter(reg, infra.NC, 3*time.Minute, 15*time.Minute)
opts := nodes.SmartRouterOptions{
Unloader: unloader,
}
if len(extraOpts) > 0 {
o := extraOpts[0]
if o.FileStager != nil {
opts.FileStager = o.FileStager
}
if o.GalleriesJSON != "" {
opts.GalleriesJSON = o.GalleriesJSON
}
if o.AuthToken != "" {
opts.AuthToken = o.AuthToken
}
if o.DB != nil {
opts.DB = o.DB
}
}
router := nodes.NewSmartRouter(reg, opts)
// Subscribe a mock backend.install handler that replies success for any node.
// We use a wildcard-style approach: subscribe to all nodes' install subjects
// by registering after each node. In practice, we rely on the test registering
// nodes before calling Route, so we subscribe to a catch-all pattern.
infra.NC.Conn().Subscribe("nodes.*.backend.install", func(msg *nats.Msg) {
reply := workerctl.BackendInstallReply{Success: true}
data, _ := json.Marshal(reply)
msg.Respond(data)
})
_, err := infra.NC.Conn().Subscribe("nodes.*.models.running", func(msg *nats.Msg) {
data, _ := json.Marshal(workerctl.ModelsRunningReply{})
_ = msg.Respond(data)
})
Expect(err).NotTo(HaveOccurred())
FlushNATS(infra.NC)
return router
}
// suppress unused warning in case some tests don't call it
_ = newTestSmartRouter
It("should route inference to a registered node with a real gRPC backend", func() {
// 1. Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// 2. Register it as a node
node := &nodes.BackendNode{
Name: "test-gpu-1",
Address: addr,
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// 3. Create SmartRouter and route a request
router := newTestSmartRouter(registry)
// The model is not loaded yet, so Route will pick the node and call LoadModel
result, err := router.Route(ctx, "", "test-model", "llama-cpp", "", &pb.ModelOptions{
Model: "test-model",
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result.Node.Name).To(Equal("test-gpu-1"))
// 4. Verify the model was loaded on the backend
Expect(llm.loaded).To(BeTrue())
// 5. Use the client to call Predict
reply, err := result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "Hello world",
})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
// 6. Release and verify in-flight decremented
result.Release()
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].InFlight).To(Equal(0))
// 7. Verify model recorded as "loaded" in registry
Expect(models[0].State).To(Equal("loaded"))
Expect(models[0].ModelName).To(Equal("test-model"))
})
It("should load-balance across multiple nodes with same model", func() {
// Start two mock gRPC backends
llm1 := &testLLM{}
addr1, cleanup1, err := startTestGRPCServer(llm1)
Expect(err).ToNot(HaveOccurred())
defer cleanup1()
llm2 := &testLLM{}
addr2, cleanup2, err := startTestGRPCServer(llm2)
Expect(err).ToNot(HaveOccurred())
defer cleanup2()
// Register both nodes
node1 := &nodes.BackendNode{Name: "node-heavy", Address: addr1}
node2 := &nodes.BackendNode{Name: "node-light", Address: addr2}
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
Expect(registry.Register(context.Background(), node2, true)).To(Succeed())
// Set both as having the model loaded
Expect(registry.SetNodeModel(context.Background(), node1.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node2.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
// Set node-1 with high in-flight (5), node-2 with low in-flight (1)
for range 5 {
Expect(registry.IncrementInFlight(context.Background(), node1.ID, "test-model", 0)).To(Succeed())
}
Expect(registry.IncrementInFlight(context.Background(), node2.ID, "test-model", 0)).To(Succeed())
// Route should pick node-2 (least loaded) thanks to ORDER BY in_flight ASC
router := newTestSmartRouter(registry)
result, err := router.Route(ctx, "", "test-model", "llama-cpp", "", nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(result.Node.Name).To(Equal("node-light"))
result.Release()
})
It("should load model on empty node when no node has it", func() {
// Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// Register a node with NO models loaded
node := &nodes.BackendNode{Name: "empty-node", Address: addr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Route should pick this node and call LoadModel on it
router := newTestSmartRouter(registry)
result, err := router.Route(ctx, "", "new-model", "llama-cpp", "", &pb.ModelOptions{
Model: "new-model",
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result.Node.Name).To(Equal("empty-node"))
Expect(llm.loaded).To(BeTrue())
// Verify model is now recorded in registry
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].ModelName).To(Equal("new-model"))
Expect(models[0].State).To(Equal("loaded"))
result.Release()
})
It("should unload remote model via NATS", func() {
// Register a node with a loaded model
node := &nodes.BackendNode{Name: "gpu-unload", Address: "127.0.0.1:50099"}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node.ID, "old-model", 0, "loaded", "", 0)).To(Succeed())
// Subscribe to NATS backend.stop for this node
stopSubject := messaging.SubjectNodeBackendStop(node.ID)
received := make(chan struct{}, 1)
rawConn, err := nats.Connect(infra.NatsURL)
Expect(err).ToNot(HaveOccurred())
defer rawConn.Close()
_, err = rawConn.Subscribe(stopSubject, func(msg *nats.Msg) {
received <- struct{}{}
})
Expect(err).ToNot(HaveOccurred())
// Create RemoteUnloaderAdapter and unload model
unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
err = unloader.UnloadRemoteModel("old-model")
Expect(err).ToNot(HaveOccurred())
// Verify NATS event received
Eventually(received, 5*time.Second).Should(Receive())
// Verify model removed from registry
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(BeEmpty())
})
It("should integrate ModelRouterAdapter with SmartRouter end-to-end", func() {
// Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// Register node
node := &nodes.BackendNode{Name: "adapter-node", Address: addr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create SmartRouter + ModelRouterAdapter
router := newTestSmartRouter(registry)
adapter := nodes.NewModelRouterAdapter(router)
// Call adapter.Route() (same signature ModelLoader uses)
m, err := adapter.Route(ctx, "llama-cpp", "test-model-id", "test-model", "", "",
&pb.ModelOptions{Model: "test-model"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(BeNil())
// Verify returned Model has correct ID and nil process (remote)
Expect(m.ID).To(Equal("test-model-id"))
Expect(m.Process()).To(BeNil())
// Verify the model was loaded on the backend
Expect(llm.loaded).To(BeTrue())
// Use the Model's GRPC() method to get a client and verify inference works
client := m.GRPC(false, nil)
Expect(client).ToNot(BeNil())
reply, err := client.Predict(ctx, &pb.PredictOptions{Prompt: "test"})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
// Release the model via adapter
adapter.ReleaseModel("test-model-id")
})
It("should stage model files via HTTP when routing to a new node", func() {
// Create a real model file on disk
modelDir := GinkgoT().TempDir()
modelContent := []byte("fake GGUF model data — this is test content for file transfer verification")
modelPath := filepath.Join(modelDir, "model.gguf")
Expect(os.WriteFile(modelPath, modelContent, 0644)).To(Succeed())
mmprojContent := []byte("fake mmproj data for multimodal projection")
mmprojPath := filepath.Join(modelDir, "mmproj.bin")
Expect(os.WriteFile(mmprojPath, mmprojContent, 0644)).To(Succeed())
// Start a real gRPC backend server (for AI RPCs) and HTTP server (for file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register the node in PostgreSQL
node := &nodes.BackendNode{Name: "staging-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager that resolves node IDs to HTTP addresses
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
// Create SmartRouter with the HTTPFileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelOptions that have file paths — SmartRouter should stage them
result, err := router.Route(ctx, "", "staged-model", "llama-cpp", "", &pb.ModelOptions{
Model: "staged-model",
ModelFile: modelPath,
MMProj: mmprojPath,
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result.Node.Name).To(Equal("staging-node"))
// Verify the model file bytes were transferred to the backend's staging dir
stagedModelPath := filepath.Join(stagingDir, "model.gguf")
stagedModelData, err := os.ReadFile(stagedModelPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedModelData).To(Equal(modelContent))
stagedMMProjPath := filepath.Join(stagingDir, "mmproj.bin")
stagedMMProjData, err := os.ReadFile(stagedMMProjPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedMMProjData).To(Equal(mmprojContent))
// Verify LoadModel was called with the rewritten (remote) paths
Expect(llm.loaded).To(BeTrue())
Expect(llm.lastModel).To(Equal(stagedModelPath))
// Verify Predict still works through the FileStagingClient wrapper
reply, err := result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "test via staging client",
})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
result.Release()
})
It("should stage multimodal input files via HTTP through FileStagingClient", func() {
// Create a real image file on disk
imageDir := GinkgoT().TempDir()
imageContent := []byte("fake JPEG image data for multimodal testing")
imagePath := filepath.Join(imageDir, "photo.jpg")
Expect(os.WriteFile(imagePath, imageContent, 0644)).To(Succeed())
// Start gRPC server (AI RPCs) and HTTP server (file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register node
node := &nodes.BackendNode{Name: "mm-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
// Create SmartRouter with FileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelOptions — triggers LoadModel on the node
modelDir := GinkgoT().TempDir()
modelPath := filepath.Join(modelDir, "vision.gguf")
Expect(os.WriteFile(modelPath, []byte("vision model data"), 0644)).To(Succeed())
result, err := router.Route(ctx, "", "vision-model", "llama-cpp", "", &pb.ModelOptions{
Model: "vision-model",
ModelFile: modelPath,
}, false)
Expect(err).ToNot(HaveOccurred())
// Verify LoadModel was called (model file was staged)
Expect(llm.loaded).To(BeTrue())
// Now call Predict with image file paths — FileStagingClient should stage them
_, err = result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "describe this image",
Images: []string{imagePath},
})
Expect(err).ToNot(HaveOccurred())
// Verify the image file was actually transferred to the backend staging dir
stagedImagePath := filepath.Join(stagingDir, "photo.jpg")
stagedImageData, err := os.ReadFile(stagedImagePath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedImageData).To(Equal(imageContent))
result.Release()
})
It("should transfer output files back via HTTP", func() {
// Start gRPC server (AI RPCs) and HTTP server (file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register node
node := &nodes.BackendNode{Name: "output-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
// Test AllocRemoteTemp + FetchRemote directly (the output retrieval path)
remoteTmpPath, err := stager.AllocRemoteTemp(ctx, node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(remoteTmpPath).ToNot(BeEmpty())
// Simulate backend writing output to the temp path
outputContent := []byte("generated image output data from the backend")
Expect(os.WriteFile(remoteTmpPath, outputContent, 0644)).To(Succeed())
// FetchRemote pulls the file from the backend to a local path
localOutputDir := GinkgoT().TempDir()
localOutputPath := filepath.Join(localOutputDir, "output.png")
err = stager.FetchRemote(ctx, node.ID, remoteTmpPath, localOutputPath)
Expect(err).ToNot(HaveOccurred())
// Verify the output file was retrieved with correct content
retrievedData, err := os.ReadFile(localOutputPath)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputContent))
})
// --- Full round-trip tests for every FileStagingClient src/dst path ---
// Helper: creates an HTTPFileStager + SmartRouter, registers a node,
// and routes to it. Returns the RouteResult (with FileStagingClient) and cleanup.
setupStagedRoute := func(llm *testLLM, backendType, modelName string) (
*nodes.RouteResult, string, func(),
) {
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
node := &nodes.BackendNode{Name: modelName + "-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
result, err := router.Route(ctx, "", modelName, backendType, "", &pb.ModelOptions{
Model: modelName,
}, false)
Expect(err).ToNot(HaveOccurred())
cleanup := func() {
cleanupGRPC()
cleanupHTTP()
}
return result, stagingDir, cleanup
}
It("should round-trip output via FileStagingClient.GenerateImage (Src + Dst)", func() {
outputData := []byte("PNG image generated by the backend - 1024x1024 pixels")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "diffusers", "sd-model")
defer cleanup()
defer result.Release()
// Create a source image to test input staging (img2img)
srcDir := GinkgoT().TempDir()
srcContent := []byte("source image for img2img")
srcPath := filepath.Join(srcDir, "src.png")
Expect(os.WriteFile(srcPath, srcContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "generated.png")
genResult, err := result.Client.GenerateImage(ctx, &pb.GenerateImageRequest{
PositivePrompt: "a cat",
Src: srcPath,
Dst: frontendDst,
Height: 1024,
Width: 1024,
})
Expect(err).ToNot(HaveOccurred())
Expect(genResult.Success).To(BeTrue())
// Verify input: Src was staged to backend — testLLM.lastSrc should be a staging dir path
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
// Verify the staged input file has correct content
stagedSrcData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedSrcData).To(Equal(srcContent))
// Verify output: the generated file was pulled back to the frontend
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip output via FileStagingClient.GenerateVideo (StartImage + Audio + Dst)", func() {
outputData := []byte("MP4 video generated by the backend")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "diffusers", "vid-model")
defer cleanup()
defer result.Release()
// Create a start image to test input staging
imgDir := GinkgoT().TempDir()
startImageContent := []byte("start frame image data")
startImagePath := filepath.Join(imgDir, "start.png")
Expect(os.WriteFile(startImagePath, startImageContent, 0644)).To(Succeed())
audioContent := []byte("audio conditioning data")
audioPath := filepath.Join(imgDir, "speech.wav")
Expect(os.WriteFile(audioPath, audioContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "generated.mp4")
genResult, err := result.Client.GenerateVideo(ctx, &pb.GenerateVideoRequest{
Prompt: "a flying cat",
StartImage: startImagePath,
Audio: audioPath,
Dst: frontendDst,
NumFrames: 16,
})
Expect(err).ToNot(HaveOccurred())
Expect(genResult.Success).To(BeTrue())
// Verify input: StartImage was staged
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
stagedStartData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedStartData).To(Equal(startImageContent))
Expect(llm.lastVideoAudio).To(ContainSubstring(stagingDir))
stagedAudioData, err := os.ReadFile(llm.lastVideoAudio)
Expect(err).ToNot(HaveOccurred())
Expect(stagedAudioData).To(Equal(audioContent))
// Verify output: video was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip output via FileStagingClient.TTS (Dst only)", func() {
outputData := []byte("WAV audio generated by TTS backend")
llm := &testLLM{dstOutput: outputData}
result, _, cleanup := setupStagedRoute(llm, "piper", "tts-model")
defer cleanup()
defer result.Release()
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "speech.wav")
ttsResult, err := result.Client.TTS(ctx, &pb.TTSRequest{
Text: "Hello world",
Model: "tts-model",
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(ttsResult.Success).To(BeTrue())
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip via FileStagingClient.SoundGeneration (Src + Dst)", func() {
outputData := []byte("generated sound effect audio data")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "bark", "soundgen-model")
defer cleanup()
defer result.Release()
// Create input audio source
srcDir := GinkgoT().TempDir()
srcContent := []byte("input audio for sound generation")
srcPath := filepath.Join(srcDir, "input.wav")
Expect(os.WriteFile(srcPath, srcContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "output.wav")
sgResult, err := result.Client.SoundGeneration(ctx, &pb.SoundGenerationRequest{
Text: "explosion sound",
Src: &srcPath,
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(sgResult.Success).To(BeTrue())
// Verify input: Src was staged
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
stagedSrcData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedSrcData).To(Equal(srcContent))
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should stage input audio via FileStagingClient.AudioTranscription (Dst is input)", func() {
llm := &testLLM{}
result, stagingDir, cleanup := setupStagedRoute(llm, "whisper", "whisper-model")
defer cleanup()
defer result.Release()
// Create input audio file
audioDir := GinkgoT().TempDir()
audioContent := []byte("WAV audio data for transcription")
audioPath := filepath.Join(audioDir, "recording.wav")
Expect(os.WriteFile(audioPath, audioContent, 0644)).To(Succeed())
// AudioTranscription uses Dst as the input audio path (confusing naming)
txResult, err := result.Client.AudioTranscription(ctx, &pb.TranscriptRequest{
Dst: audioPath,
})
Expect(err).ToNot(HaveOccurred())
Expect(txResult.Text).To(Equal("transcribed text"))
// Verify input: audio file was staged to the backend
Expect(llm.lastAudioDst).To(ContainSubstring(stagingDir))
stagedAudioData, err := os.ReadFile(llm.lastAudioDst)
Expect(err).ToNot(HaveOccurred())
Expect(stagedAudioData).To(Equal(audioContent))
})
It("should translate TTS Model path to remote worker path", func() {
outputData := []byte("WAV audio generated by TTS backend")
llm := &testLLM{dstOutput: outputData}
// Set up real file transfer server so model staging preserves directory structure
modelsDir := GinkgoT().TempDir()
stagingDir := GinkgoT().TempDir()
dataDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpLis, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
httpAddr := httpLis.Addr().String()
httpServer, err := nodes.StartFileTransferServerWithListener(httpLis, stagingDir, modelsDir, dataDir, "", 0)
Expect(err).ToNot(HaveOccurred())
defer nodes.ShutdownFileTransferServer(httpServer)
node := &nodes.BackendNode{Name: "tts-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
// Create model files on the "frontend"
frontendModelsDir := GinkgoT().TempDir()
modelContent := []byte("fake onnx model data")
configContent := []byte(`{"audio":{"sample_rate":22050}}`)
modelFile := filepath.Join(frontendModelsDir, "it-paola-medium.onnx")
configFile := filepath.Join(frontendModelsDir, "it-paola-medium.onnx.json")
Expect(os.WriteFile(modelFile, modelContent, 0644)).To(Succeed())
Expect(os.WriteFile(configFile, configContent, 0644)).To(Succeed())
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelFile pointing to the .onnx file (triggers model staging)
result, err := router.Route(ctx, "voice-it-paola-medium", "it-paola-medium.onnx", "piper", "", &pb.ModelOptions{
Model: "it-paola-medium.onnx",
ModelFile: modelFile,
}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "speech.wav")
// Simulate what core/backend/tts.go does: construct Model path using frontend ModelPath
frontendModelPath := filepath.Join(frontendModelsDir, "it-paola-medium.onnx")
ttsResult, err := result.Client.TTS(ctx, &pb.TTSRequest{
Text: "Hello world",
Model: frontendModelPath, // frontend absolute path — should be translated to remote
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(ttsResult.Success).To(BeTrue())
// Verify: the backend received the remote worker path, NOT the frontend path
Expect(llm.lastTTSModel).ToNot(Equal(frontendModelPath))
// The remote path should be under the worker's models dir with the tracking key
Expect(llm.lastTTSModel).To(ContainSubstring("voice-it-paola-medium"))
Expect(llm.lastTTSModel).To(HaveSuffix("it-paola-medium.onnx"))
// Verify the model file exists at the translated path (already staged during LoadModel)
stagedModelData, err := os.ReadFile(llm.lastTTSModel)
Expect(err).ToNot(HaveOccurred())
Expect(stagedModelData).To(Equal(modelContent))
// Verify the companion .onnx.json is next to it (staged during LoadModel)
companionPath := llm.lastTTSModel + ".json"
stagedConfigData, err := os.ReadFile(companionPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedConfigData).To(Equal(configContent))
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should stage companion .onnx.json files alongside .onnx model files", func() {
llm := &testLLM{}
modelsDir := GinkgoT().TempDir()
stagingDir := GinkgoT().TempDir()
dataDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
// Use the real file transfer server (preserves directory structure)
httpLis, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
httpAddr := httpLis.Addr().String()
httpServer, err := nodes.StartFileTransferServerWithListener(httpLis, stagingDir, modelsDir, dataDir, "", 0)
Expect(err).ToNot(HaveOccurred())
defer nodes.ShutdownFileTransferServer(httpServer)
node := &nodes.BackendNode{Name: "companion-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", nodes.DirectWorkerNetDialer())
// Create model files: .onnx and .onnx.json in a temp "models" dir
frontendModelsDir := GinkgoT().TempDir()
modelContent := []byte("fake onnx model")
configContent := []byte(`{"audio":{"sample_rate":22050}}`)
modelFile := filepath.Join(frontendModelsDir, "my-model.onnx")
configFile := filepath.Join(frontendModelsDir, "my-model.onnx.json")
Expect(os.WriteFile(modelFile, modelContent, 0644)).To(Succeed())
Expect(os.WriteFile(configFile, configContent, 0644)).To(Succeed())
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelFile pointing to the .onnx file
result, err := router.Route(ctx, "piper-companion-test", "my-model.onnx", "piper", "", &pb.ModelOptions{
Model: "my-model.onnx",
ModelFile: modelFile,
}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// Verify: both .onnx and .onnx.json were staged to the worker's models dir
stagedOnnx := filepath.Join(modelsDir, "piper-companion-test", "my-model.onnx")
stagedConfig := filepath.Join(modelsDir, "piper-companion-test", "my-model.onnx.json")
stagedOnnxData, err := os.ReadFile(stagedOnnx)
Expect(err).ToNot(HaveOccurred(), "companion .onnx model should be staged")
Expect(stagedOnnxData).To(Equal(modelContent))
stagedConfigData, err := os.ReadFile(stagedConfig)
Expect(err).ToNot(HaveOccurred(), "companion .onnx.json config should be staged alongside model")
Expect(stagedConfigData).To(Equal(configContent))
})
})