Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/cpp/audio-cpp/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# recipe is a make target (not a prepare.sh) so 'make purge && make' is a clean
# rebuild and so the bump bot can see the pin.

AUDIO_CPP_VERSION?=17751c0e8c48a3d56dcf05eeb60464409ecc69ce
AUDIO_CPP_VERSION?=89a0e9803380880305e9e1b83c93614f9df2c893
AUDIO_CPP_REPO?=https://github.com/0xShug0/audio.cpp

CURRENT_MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
Expand Down
2 changes: 1 addition & 1 deletion backend/cpp/llama-cpp/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

LLAMA_VERSION?=e70802a01f03f0ed31a26338a5664796f3824371
LLAMA_VERSION?=d7bd3bfcad3e29c7e49fd26f38c79ee3e9a3fd6b
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp

CMAKE_ARGS?=
Expand Down
2 changes: 1 addition & 1 deletion backend/python/sglang/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ def _build_sampling_params(self, request) -> dict:
if not hasattr(request, proto_field):
continue
value = getattr(request, proto_field)
if value in (None, 0, 0.0, [], False, ""):
if proto_field != "Temperature" and value in (None, 0, 0.0, [], False, ""):
continue
# repeated fields come back as RepeatedScalarContainer — convert
if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)):
Expand Down
29 changes: 29 additions & 0 deletions backend/python/sglang/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ def kwargs_for(metadata):
self.assertNotIn("enable_thinking", kwargs_for({}))
self.assertIs(kwargs_for({"enable_thinking": "FALSE"})["enable_thinking"], False)

def test_explicit_zero_temperature_is_preserved(self):
"""Temperature=0 is valid greedy decoding, not an unset value."""
from types import SimpleNamespace

servicer = self._servicer()
request = SimpleNamespace(
Temperature=0,
N=0,
PresencePenalty=0,
FrequencyPenalty=0,
RepetitionPenalty=0,
TopP=0,
TopK=0,
MinP=0,
Seed=0,
StopPrompts=[],
StopTokenIds=[],
IgnoreEOS=False,
Tokens=0,
MinTokens=0,
SkipSpecialTokens=False,
Grammar="",
)

params = servicer._build_sampling_params(request)
self.assertEqual(params["temperature"], 0)
# Other protobuf-default scalar fields must remain filtered.
self.assertNotIn("top_p", params)


if __name__ == "__main__":
unittest.main()
12 changes: 8 additions & 4 deletions backend/python/vllm/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,7 @@ async def Score(self, request, context):
context.set_details(str(e))
return backend_pb2.ScoreResponse()

async def _predict(self, request, context, streaming=False):
# Build the sampling parameters
# NOTE: this must stay in sync with the vllm backend
def _build_sampling_params(self, request):
request_to_sampling_params = {
"N": "n",
"PresencePenalty": "presence_penalty",
Expand Down Expand Up @@ -555,9 +553,15 @@ async def _predict(self, request, context, streaming=False):
for request_field, param_field in request_to_sampling_params.items():
if hasattr(request, request_field):
value = getattr(request, request_field)
if value not in (None, 0, [], False):
if request_field == "Temperature" or value not in (None, 0, [], False):
setattr(sampling_params, param_field, value)

return sampling_params

async def _predict(self, request, context, streaming=False):
# Build the sampling parameters
sampling_params = self._build_sampling_params(request)

# Structured-output decoding: use Grammar field to pass JSON schema or BNF
if HAS_GUIDED_DECODING and request.Grammar:
try:
Expand Down
13 changes: 13 additions & 0 deletions backend/python/vllm/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ def test_sampling_params(self):
finally:
self.tearDown()

def test_explicit_zero_temperature_is_preserved(self):
"""Temperature=0 is valid greedy decoding, not an unset value."""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from backend import BackendServicer

servicer = BackendServicer()
request = backend_pb2.PredictOptions(Prompt="hello", Temperature=0)
sampling_params = servicer._build_sampling_params(request)
self.assertEqual(sampling_params.temperature, 0)
# Other protobuf-default scalar fields must remain filtered.
self.assertEqual(sampling_params.top_p, 0.9)


def test_messages_to_dicts(self):
"""
Expand Down
9 changes: 9 additions & 0 deletions core/application/distributed.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
}
xlog.Info("Node registry initialized")

// Let scheduling rules be keyed by a model alias. The registry resolves a
// rule's name through the config loader to find the model it governs, so an
// operator can pin placement to a stable name like "production" and have it
// follow the alias when the alias is repointed. Wired before the seed below
// and before the reconciler starts, so the first tick already resolves.
if configLoader != nil {
registry.SetAliasResolver(configLoader)
}

// Seed declarative per-model scheduling config (LOCALAI_MODEL_SCHEDULING /
// LOCALAI_MODEL_SCHEDULING_CONFIG). Authoritative: overwrites matching models
// on every boot. Runs before the reconciler starts so the first tick already
Expand Down
4 changes: 3 additions & 1 deletion core/config/inference_defaults.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"_comment": "Auto-generated from unsloth inference_defaults.json. DO NOT EDIT. Run go generate ./core/config/ to update.",
"families": {
"moss-tts-local-transformer-v1.5": {"min_p":0,"repeat_penalty":1,"temperature":1.7,"top_k":25,"top_p":0.8},
"moss-tts-nano": {"min_p":0,"repeat_penalty":1,"temperature":1.7,"top_k":25,"top_p":0.8},
"qwen3.8": {"min_p":0,"presence_penalty":1.5,"repeat_penalty":1,"temperature":0.7,"top_k":20,"top_p":0.8},
"qwen3.6": {"min_p":0,"presence_penalty":1.5,"repeat_penalty":1,"temperature":0.7,"top_k":20,"top_p":0.8},
"qwen3.5": {"min_p":0,"presence_penalty":1.5,"repeat_penalty":1,"temperature":0.7,"top_k":20,"top_p":0.8},
Expand Down Expand Up @@ -60,5 +62,5 @@
"grok": {"min_p":0.01,"repeat_penalty":1,"temperature":1,"top_k":-1,"top_p":0.95},
"mimo": {"min_p":0.01,"repeat_penalty":1,"temperature":0.7,"top_k":-1,"top_p":0.95}
},
"patterns": ["qwen3.8","qwen3.6","qwen3.5","qwen3-coder","qwen3-next","qwen3-vl","qwen3","qwen2.5-coder","qwen2.5-vl","qwen2.5-omni","qwen2.5-math","qwen2.5","qwen2-vl","qwen2","qwq","gemma-4","gemma-3n","gemma-3","medgemma","gemma-2","muse-glimmer","llama-4","llama-3.3","llama-3.2","llama-3.1","llama-3","phi-4","phi-3","mistral-nemo","mistral-small","mistral-large","magistral","ministral","devstral","pixtral","deepseek-v4","deepseek-r1","deepseek-v3","deepseek-ocr","glm-5","glm-4","nemotron","minimax-m2.7","minimax-m2.5","minimax","gpt-oss","granite-4","kimi-k3","kimi-k2","kimi","lfm2","smollm","olmo","falcon","ernie","seed","grok","mimo"]
"patterns": ["moss-tts-local-transformer-v1.5","moss-tts-nano","qwen3.8","qwen3.6","qwen3.5","qwen3-coder","qwen3-next","qwen3-vl","qwen3","qwen2.5-coder","qwen2.5-vl","qwen2.5-omni","qwen2.5-math","qwen2.5","qwen2-vl","qwen2","qwq","gemma-4","gemma-3n","gemma-3","medgemma","gemma-2","muse-glimmer","llama-4","llama-3.3","llama-3.2","llama-3.1","llama-3","phi-4","phi-3","mistral-nemo","mistral-small","mistral-large","magistral","ministral","devstral","pixtral","deepseek-v4","deepseek-r1","deepseek-v3","deepseek-ocr","glm-5","glm-4","nemotron","minimax-m2.7","minimax-m2.5","minimax","gpt-oss","granite-4","kimi-k3","kimi-k2","kimi","lfm2","smollm","olmo","falcon","ernie","seed","grok","mimo"]
}
20 changes: 20 additions & 0 deletions core/config/model_config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,26 @@ func (bcl *ModelConfigLoader) ResolveAlias(cfg *ModelConfig) (*ModelConfig, bool
return &target, true, nil
}

// ResolveAliasName maps a model name to the name of the model that actually
// serves it: an alias resolves to its target, anything else resolves to
// itself. The second return reports whether name was an alias.
//
// Unlike ResolveAlias this never errors. A name with no config (a rule may be
// authored before the model is installed), a dangling alias, and a chained
// alias all resolve to themselves, so callers keep a usable name that simply
// has no model behind it rather than silently governing a different model.
func (bcl *ModelConfigLoader) ResolveAliasName(name string) (string, bool) {
cfg, exists := bcl.GetModelConfig(name)
if !exists || !cfg.IsAlias() {
return name, false
}
target, exists := bcl.GetModelConfig(cfg.Alias)
if !exists || target.IsAlias() {
return name, true
}
return target.Name, true
}

// ValidateAliasTarget checks an alias config's target at create/swap time:
// the target must exist, must not be an alias, and must not be disabled.
// Returns nil for non-alias configs.
Expand Down
54 changes: 54 additions & 0 deletions core/config/model_config_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,57 @@ var _ = Describe("ModelConfigLoader alias resolution", func() {
Expect(loader.ValidateAliasTarget(&bad)).To(MatchError(ContainSubstring("itself an alias")))
})
})

var _ = Describe("ModelConfigLoader ResolveAliasName", func() {
var loader *ModelConfigLoader

BeforeEach(func() {
loader = NewModelConfigLoader("")
loader.configs["real"] = ModelConfig{Name: "real", Backend: "llama-cpp"}
loader.configs["production"] = ModelConfig{Name: "production", Alias: "real"}
loader.configs["chain"] = ModelConfig{Name: "chain", Alias: "production"}
loader.configs["dangling"] = ModelConfig{Name: "dangling", Alias: "nope"}
})

It("maps an alias name to the model that actually serves it", func() {
target, isAlias := loader.ResolveAliasName("production")
Expect(isAlias).To(BeTrue())
Expect(target).To(Equal("real"))
})

It("maps a real model name to itself", func() {
target, isAlias := loader.ResolveAliasName("real")
Expect(isAlias).To(BeFalse())
Expect(target).To(Equal("real"))
})

// A rule may be authored for a model that is not installed yet (pre-staging
// placement before standing up a node), so an unknown name must resolve to
// itself rather than to the empty string.
It("maps an unknown name to itself", func() {
target, isAlias := loader.ResolveAliasName("not-installed-yet")
Expect(isAlias).To(BeFalse())
Expect(target).To(Equal("not-installed-yet"))
})

// A broken alias has no model behind it. Resolving to itself keeps the
// caller on a name that simply has no replicas, instead of silently
// governing some other model.
It("maps a dangling alias to itself", func() {
target, isAlias := loader.ResolveAliasName("dangling")
Expect(isAlias).To(BeTrue())
Expect(target).To(Equal("dangling"))
})

It("maps a chained alias to itself rather than following the chain", func() {
target, isAlias := loader.ResolveAliasName("chain")
Expect(isAlias).To(BeTrue())
Expect(target).To(Equal("chain"))
})

It("maps the empty name to itself", func() {
target, isAlias := loader.ResolveAliasName("")
Expect(isAlias).To(BeFalse())
Expect(target).To(BeEmpty())
})
})
15 changes: 15 additions & 0 deletions core/http/endpoints/localai/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,20 @@ func SetSchedulingEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, err.Error()))
}

// A rule may be keyed by an alias, in which case it governs whatever
// that alias currently points at. Reject an alias that resolves to
// nothing, and reject a second rule for a model some other rule already
// governs, so the operator hears about the clash instead of silently
// writing a rule that never takes effect.
target, err := registry.ValidateSchedulingTarget(ctx, req.ModelName)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, nodes.ErrSchedulingConflict) {
status = http.StatusConflict
}
return c.JSON(status, nodeError(status, err.Error()))
}

// Serialize node selector to JSON
var selectorJSON string
if len(req.NodeSelector) > 0 {
Expand All @@ -1230,6 +1244,7 @@ func SetSchedulingEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {

config := &nodes.ModelSchedulingConfig{
ModelName: req.ModelName,
TargetModel: target,
NodeSelector: selectorJSON,
MinReplicas: req.MinReplicas,
MaxReplicas: req.MaxReplicas,
Expand Down
125 changes: 125 additions & 0 deletions core/http/endpoints/localai/nodes_scheduling_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package localai

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"

"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/testutil"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

// aliasResolverStub maps alias names to targets in place of a config loader.
type aliasResolverStub struct{ aliases map[string]string }

func (s *aliasResolverStub) ResolveAliasName(name string) (string, bool) {
target, ok := s.aliases[name]
if !ok {
return name, false
}
return target, true
}

var _ = Describe("Scheduling endpoints with model aliases", func() {
var (
registry *nodes.NodeRegistry
resolver *aliasResolverStub
)

BeforeEach(func() {
db := testutil.SetupTestDB()
var err error
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
resolver = &aliasResolverStub{aliases: map[string]string{"production": "qwen3"}}
registry.SetAliasResolver(resolver)
})

post := func(body string) *httptest.ResponseRecorder {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
ExpectWithOffset(1, SetSchedulingEndpoint(registry)(c)).To(Succeed())
return rec
}

It("accepts a rule keyed by an alias and reports the model it governs", func() {
rec := post(`{"model_name":"production","min_replicas":2,"node_selector":{"tier":"gpu"}}`)
Expect(rec.Code).To(Equal(http.StatusOK))

var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
Expect(resp["model_name"]).To(Equal("production"))
Expect(resp["target_model"]).To(Equal("qwen3"))
})

It("rejects a second rule for a model an alias rule already governs", func() {
Expect(post(`{"model_name":"production","min_replicas":2}`).Code).To(Equal(http.StatusOK))

rec := post(`{"model_name":"qwen3","min_replicas":1}`)
Expect(rec.Code).To(Equal(http.StatusConflict))
Expect(rec.Body.String()).To(ContainSubstring("production"))
})

It("rejects an alias rule for a model that already has its own rule", func() {
Expect(post(`{"model_name":"qwen3","min_replicas":1}`).Code).To(Equal(http.StatusOK))

rec := post(`{"model_name":"production","min_replicas":2}`)
Expect(rec.Code).To(Equal(http.StatusConflict))
Expect(rec.Body.String()).To(ContainSubstring("qwen3"))
})

It("still allows editing a rule in place", func() {
Expect(post(`{"model_name":"production","min_replicas":2}`).Code).To(Equal(http.StatusOK))

rec := post(`{"model_name":"production","min_replicas":4}`)
Expect(rec.Code).To(Equal(http.StatusOK))

stored, err := registry.GetModelScheduling(context.Background(), "production")
Expect(err).ToNot(HaveOccurred())
Expect(stored.MinReplicas).To(Equal(4))
})

It("rejects a rule keyed by an alias that does not resolve", func() {
resolver.aliases["orphan"] = "orphan"

rec := post(`{"model_name":"orphan","min_replicas":1}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Body.String()).To(ContainSubstring("does not resolve"))
})

It("still accepts a rule for a model that is not installed yet", func() {
rec := post(`{"model_name":"not-installed-yet","min_replicas":1}`)
Expect(rec.Code).To(Equal(http.StatusOK))
})

It("labels a rule that another rule shadows when listing", func() {
// A seed file or a repointed alias can leave two rules on one model,
// which the write path above rejects but cannot retract.
Expect(registry.SetModelScheduling(context.Background(), &nodes.ModelSchedulingConfig{ModelName: "production", MinReplicas: 2})).To(Succeed())
Expect(registry.SetModelScheduling(context.Background(), &nodes.ModelSchedulingConfig{ModelName: "qwen3", MinReplicas: 1})).To(Succeed())

e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
Expect(ListSchedulingEndpoint(registry)(c)).To(Succeed())

var listed []map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &listed)).To(Succeed())
byName := map[string]map[string]any{}
for _, item := range listed {
byName[item["model_name"].(string)] = item
}
Expect(byName["qwen3"]["shadowed"]).To(BeNil())
Expect(byName["production"]["shadowed"]).To(Equal(true))
})
})
Loading
Loading