mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 14:14:54 -04:00
Five cluster specs that run the binaries an operator runs, plus the repair
of eighteen specs phase 2 left red.
The eighteen were router_tracking and full_flow, failing since 1cf847f29 on
"reported backend installed but named no address for the process". Two
contracts had changed under them: an install reply that names no
worker-local address is refused rather than substituted, and a frontend with
no worker dialer reaches no backend at all. Nobody noticed for a phase
because phase 2 verified with --label-filter='Cluster', which excludes both
suites. ServeBackendLifecycle and tunnelBackendClients state both facts once
for every spec.
The transport double is the part that matters. It translates a refused
connect into cluster.ErrStreamTargetUnavailable, which is what a real worker
answers when its backend process has died and what IsWorkerAnswer lets a
reap guard act on. A bare ECONNREFUSED reaches those guards as "no route" and
reaps nothing, so a double returning the raw syscall error could never fail
the way production fails; putting it back reddens the stale-record spec and
nothing else.
The new specs cover: a backend worker with no bus URL in its /proc environ
registering, being scheduled onto and serving inference; a backend install
and a backend listing driven through the replica that does NOT own the
worker, with the owner read through the production Owner query and re-read
after; that install's progress proven to arrive before its terminal reply,
made deterministic by a gallery server that holds the worker's fetch open so
a reply cannot exist yet; a worker whose tunnel is genuinely gone, waited for
rather than assumed, losing nothing inside the reconnect grace and re-homing
after; a heartbeating worker with a permanently dead tunnel losing its
healthy status while an agent worker in the same cluster keeps it; and the
suite's negative control, where a control RPC to a tunnel-less worker fails
naming the missing route, reaps nothing, and succeeds the moment the tunnel
returns.
Every scenario was attacked. The churn one was WRONG on the first attempt
and only the attack found it: its hold window sat entirely inside
cluster.InstanceLiveness, so a killed replica still read as a live owner
throughout, presence was "connected", and the spec passed with the reconnect
grace set to a nanosecond. It now blocks the tunnel before the kill and waits
for the ownership row to actually empty. Attacks that redden the rest:
posting at the owner, writing the install reply before the work, collapsing
PresenceReconnecting into PresenceGone, removing the non-backend node-type
guard, and not blocking the tunnel. Agent workers turn out to be protected
twice over; no single mutation reaches them.
Harness: Options.AgentWorkers and Options.ReconnectGrace, WorkerEnviron
(read from /proc, because Cmd.Env is the harness agreeing with itself),
NatsURL, FrontendBackendsDir, AgentWorkerName, PostJSON, and a node String()
so a failing roster assertion is readable instead of several hundred bytes
rendered as numbers.
Budget: 20 specs at 787 to 808 seconds over three runs, up from phase 2's 591
to 612. --timeout goes to 30m so a loaded runner reports a cause rather than
a spec name.
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
994 lines
35 KiB
Go
994 lines
35 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"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"
|
|
|
|
"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 reaching a fleet of fake workers
|
|
// over the tunnelled control plane, with a backend.install handler that
|
|
// replies success for every registered node and names where that node's
|
|
// backend process listens.
|
|
//
|
|
// Both halves of the post-tunnel contract are here rather than in the
|
|
// specs, because they are the same two facts in every one of them: an
|
|
// install reply that names no process address is refused
|
|
// (installBackendOnNode), and a frontend with no worker dialer reaches no
|
|
// backend at all (nodes.ErrNoWorkerDialer).
|
|
newTestSmartRouter := func(reg *nodes.NodeRegistry, extraOpts ...nodes.SmartRouterOptions) *nodes.SmartRouter {
|
|
workers := NewControlWorkers()
|
|
workers.ServeBackendLifecycle(reg)
|
|
unloader := nodes.NewRemoteUnloaderAdapter(reg, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
|
|
opts := nodes.SmartRouterOptions{
|
|
Unloader: unloader,
|
|
ClientFactory: tunnelBackendClients(),
|
|
}
|
|
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)
|
|
|
|
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
|
|
// The address is where that node's backend process listens, which is
|
|
// what a real install reply would have recorded on the row. A row
|
|
// carrying none names no process, so the router would re-install rather
|
|
// than route to it and this spec would be measuring the install path.
|
|
Expect(registry.SetNodeModel(context.Background(), node1.ID, "test-model", 0, "loaded", addr1, 0)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node2.ID, "test-model", 0, "loaded", addr2, 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 a remote model over the worker's control plane", 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())
|
|
|
|
// A worker serving backend.stop on its own control plane.
|
|
received := make(chan struct{}, 1)
|
|
workers := NewControlWorkers()
|
|
workers.On(node.ID, workerctl.PathBackendStop, func(string, []byte) any {
|
|
received <- struct{}{}
|
|
return nil
|
|
})
|
|
|
|
// Create RemoteUnloaderAdapter and unload model
|
|
unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
Expect(unloader.UnloadRemoteModel("old-model")).To(Succeed())
|
|
|
|
// The worker got the stop over its tunnel, not over the bus.
|
|
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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
// 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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
// 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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
// 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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
// 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
|
|
}, "", directWorkerDialerFor)
|
|
|
|
// 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))
|
|
})
|
|
})
|