diff --git a/backend/cpp/audio-cpp/Makefile b/backend/cpp/audio-cpp/Makefile
index aefdac17ab8d..837e84b7fdf8 100644
--- a/backend/cpp/audio-cpp/Makefile
+++ b/backend/cpp/audio-cpp/Makefile
@@ -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))))
diff --git a/backend/cpp/llama-cpp/Makefile b/backend/cpp/llama-cpp/Makefile
index 196023dbf4f7..b1f34d9852ac 100644
--- a/backend/cpp/llama-cpp/Makefile
+++ b/backend/cpp/llama-cpp/Makefile
@@ -1,5 +1,5 @@
-LLAMA_VERSION?=e70802a01f03f0ed31a26338a5664796f3824371
+LLAMA_VERSION?=d7bd3bfcad3e29c7e49fd26f38c79ee3e9a3fd6b
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=
diff --git a/backend/python/sglang/backend.py b/backend/python/sglang/backend.py
index ad6c6ca10197..76d99a726aaf 100644
--- a/backend/python/sglang/backend.py
+++ b/backend/python/sglang/backend.py
@@ -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)):
diff --git a/backend/python/sglang/test.py b/backend/python/sglang/test.py
index deb615883b5e..c50ed577f941 100644
--- a/backend/python/sglang/test.py
+++ b/backend/python/sglang/test.py
@@ -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()
diff --git a/backend/python/vllm/backend.py b/backend/python/vllm/backend.py
index f3f01ec45214..7235c8e0737a 100644
--- a/backend/python/vllm/backend.py
+++ b/backend/python/vllm/backend.py
@@ -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",
@@ -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:
diff --git a/backend/python/vllm/test.py b/backend/python/vllm/test.py
index d00595f016fc..a0679d4ff8e5 100644
--- a/backend/python/vllm/test.py
+++ b/backend/python/vllm/test.py
@@ -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):
"""
diff --git a/core/application/distributed.go b/core/application/distributed.go
index 8389c5c9f3b8..b7dc0bf91351 100644
--- a/core/application/distributed.go
+++ b/core/application/distributed.go
@@ -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
diff --git a/core/config/inference_defaults.json b/core/config/inference_defaults.json
index 8fe888de1902..42c463df85b5 100644
--- a/core/config/inference_defaults.json
+++ b/core/config/inference_defaults.json
@@ -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},
@@ -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"]
}
diff --git a/core/config/model_config_loader.go b/core/config/model_config_loader.go
index b91449ff01bc..9120cc2685ca 100644
--- a/core/config/model_config_loader.go
+++ b/core/config/model_config_loader.go
@@ -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.
diff --git a/core/config/model_config_loader_test.go b/core/config/model_config_loader_test.go
index 87807deec86b..d654226efb46 100644
--- a/core/config/model_config_loader_test.go
+++ b/core/config/model_config_loader_test.go
@@ -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())
+ })
+})
diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go
index bc26baf49024..bbae523b1025 100644
--- a/core/http/endpoints/localai/nodes.go
+++ b/core/http/endpoints/localai/nodes.go
@@ -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 {
@@ -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,
diff --git a/core/http/endpoints/localai/nodes_scheduling_alias_test.go b/core/http/endpoints/localai/nodes_scheduling_alias_test.go
new file mode 100644
index 000000000000..35065f614922
--- /dev/null
+++ b/core/http/endpoints/localai/nodes_scheduling_alias_test.go
@@ -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))
+ })
+})
diff --git a/core/http/react-ui/e2e/scheduling.spec.js b/core/http/react-ui/e2e/scheduling.spec.js
index 4ce06cb4a7ee..79d781a3fc43 100644
--- a/core/http/react-ui/e2e/scheduling.spec.js
+++ b/core/http/react-ui/e2e/scheduling.spec.js
@@ -176,6 +176,75 @@ test.describe('Scheduling page', () => {
await expect(page.getByLabel('Node selector').getByText('gpu.vendor=nvidia', { exact: true })).toBeVisible()
})
+ // A rule may be keyed by an alias, in which case it governs whichever model
+ // the alias points at. The page has to say which model that is, because the
+ // rule's own name no longer tells you.
+ test.describe('rules keyed by a model alias', () => {
+ const aliasRule = {
+ model_name: 'production',
+ target_model: 'llama-3.3',
+ model_is_alias: true,
+ node_selector: { tier: 'gpu' },
+ min_replicas: 2,
+ max_replicas: 4,
+ }
+
+ async function mockAliases(page, aliases = [{ name: 'production', target: 'llama-3.3' }]) {
+ await page.route('**/api/aliases', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(aliases),
+ }))
+ await page.route('**/api/models/capabilities', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ object: 'list', data: [{ id: 'llama-3.3' }, { id: 'production' }] }),
+ }))
+ }
+
+ test('names the model an alias rule governs', async ({ page }) => {
+ await mockScheduling(page, { rules: [aliasRule] })
+ await mockAliases(page)
+ await page.goto('/app/scheduling')
+
+ await expect(page.getByText('production')).toBeVisible()
+ await expect(page.locator('.scheduling-rule-target')).toHaveText(/llama-3\.3/)
+ })
+
+ test('marks a rule another rule already governs as shadowed', async ({ page }) => {
+ await mockScheduling(page, { rules: [{ ...aliasRule, shadowed: true }, rule] })
+ await mockAliases(page)
+ await page.goto('/app/scheduling')
+
+ await expect(page.locator('.scheduling-rule-shadowed')).toHaveCount(1)
+ await expect(page.locator('.scheduling-rule-shadowed')).toContainText('Shadowed')
+ })
+
+ test('flags an alias rule that no longer resolves', async ({ page }) => {
+ await mockScheduling(page, {
+ rules: [{ model_name: 'orphan', target_model: 'orphan', model_is_alias: true, min_replicas: 1 }],
+ })
+ await mockAliases(page, [])
+ await page.goto('/app/scheduling')
+
+ await expect(page.locator('.scheduling-rule-target--broken')).toBeVisible()
+ })
+
+ test('offers aliases in the model picker, tagged with their target', async ({ page }) => {
+ await mockScheduling(page)
+ await mockAliases(page)
+ await page.goto('/app/scheduling')
+ await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
+
+ const picker = page.locator('.searchable-model-select input')
+ await picker.click()
+ await expect(page.locator('.sms-hint')).toHaveText('alias of llama-3.3')
+
+ await page.getByRole('option', { name: /production/ }).click()
+ await expect(page.getByText(/production is an alias for llama-3\.3/)).toBeVisible()
+ })
+ })
+
test('keeps rule actions reachable on a narrow viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockScheduling(page, { nodeList: nodes.slice(0, 2) })
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index a5af8fbc63a0..679e16522a0d 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -2781,6 +2781,31 @@ select.input {
justify-content: flex-end;
}
+/* Second line of a rule's Model cell: the model an alias-keyed rule currently
+ governs. The cell itself is bold, so the weight is reset here rather than
+ inherited. */
+.scheduling-rule-target {
+ font-weight: 400;
+}
+
+.scheduling-rule-target--broken {
+ font-weight: 400;
+ color: var(--color-warning);
+}
+
+/* Status pill for a rule another rule already governs, so it has no effect.
+ Mirrors the unsatisfiable pill's shape. */
+.scheduling-rule-shadowed {
+ display: inline-block;
+ font-size: var(--text-xs);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-weight: 600;
+ background: var(--color-bg-tertiary);
+ border: 1px solid var(--color-warning);
+ color: var(--color-warning);
+}
+
@media (max-width: 640px) {
.scheduling-rule-actions {
width: 100%;
diff --git a/core/http/react-ui/src/components/SearchableModelSelect.jsx b/core/http/react-ui/src/components/SearchableModelSelect.jsx
index 3d920fa4dfb4..f63902956efd 100644
--- a/core/http/react-ui/src/components/SearchableModelSelect.jsx
+++ b/core/http/react-ui/src/components/SearchableModelSelect.jsx
@@ -7,7 +7,10 @@ import { useModels } from '../hooks/useModels'
// query isn't treated as a chosen value. After a commit the field is cleared,
// matching the add-and-clear flow. Default false keeps the as-you-type
// behaviour single-value editors rely on.
-export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style, commitOnly = false }) {
+// hints: optional { [modelId]: string } shown as muted text beside an entry and
+// searchable along with the name. Used to mark aliases with the model they
+// point at, so a picker that lists both can tell them apart.
+export default function SearchableModelSelect({ value, onChange, capability, placeholder = 'Type or select a model...', style, commitOnly = false, hints = {} }) {
const { models, loading } = useModels(capability)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
@@ -29,8 +32,10 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
return () => document.removeEventListener('mousedown', handler)
}, [])
+ const needle = query.toLowerCase()
const filtered = models.filter(m =>
- m.id.toLowerCase().includes(query.toLowerCase())
+ m.id.toLowerCase().includes(needle) ||
+ (hints[m.id] || '').toLowerCase().includes(needle)
)
// Which item Enter will select — matches SearchableSelect behavior
@@ -126,6 +131,11 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
color: var(--color-primary);
font-weight: 600;
}
+ .sms-hint {
+ color: var(--color-text-muted);
+ font-size: 0.75rem;
+ flex-shrink: 0;
+ }
.sms-empty {
padding: 8px 10px;
font-size: 0.8125rem;
@@ -172,6 +182,9 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
}}
>
{m.id}
+ {hints[m.id] && (
+ {hints[m.id]}
+ )}
{isEnterTarget && (
↵
)}
diff --git a/core/http/react-ui/src/pages/Scheduling.jsx b/core/http/react-ui/src/pages/Scheduling.jsx
index 1558281c3226..fc2343ece1a3 100644
--- a/core/http/react-ui/src/pages/Scheduling.jsx
+++ b/core/http/react-ui/src/pages/Scheduling.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import { nodesApi } from '../utils/api'
+import { nodesApi, modelsApi } from '../utils/api'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import ResponsiveTable from '../components/ResponsiveTable'
@@ -66,7 +66,7 @@ function configMode(config) {
return 'placement'
}
-function SchedulingForm({ initialConfig, onSave, onCancel, labels }) {
+function SchedulingForm({ initialConfig, onSave, onCancel, labels, aliases }) {
const [mode, setMode] = useState(() => configMode(initialConfig))
const [modelName, setModelName] = useState(initialConfig?.model_name || '')
// Selector is now a chip-builder map instead of a comma-separated string.
@@ -84,6 +84,10 @@ function SchedulingForm({ initialConfig, onSave, onCancel, labels }) {
const [minPrefixMatch, setMinPrefixMatch] = useState(initialConfig?.min_prefix_match ?? 0)
const hasSelector = Object.keys(selector).length > 0
+ // Aliases are listed in the picker alongside models, tagged with the model
+ // they resolve to so the two are distinguishable in one flat list.
+ const aliasHints = Object.fromEntries(Object.entries(aliases || {}).map(([name, target]) => [name, `alias of ${target}`]))
+ const aliasTarget = (aliases || {})[modelName]
const isValid = () => {
if (!modelName) return false
@@ -159,9 +163,20 @@ function SchedulingForm({ initialConfig, onSave, onCancel, labels }) {