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 }) { )} + {/* An alias is a stable name for whichever model currently serves it, + so a rule on one is a rule on a slot rather than on a model. Say + so at the point of choosing, because the consequence (repointing + the alias carries the rule along) is not visible anywhere else. */} + {aliasTarget && ( + +