test(studio): guard alias identity and persistence boundaries

This commit is contained in:
XTLine committed 2026-10-12 19:51:04 +08:00
1 parent f9e3f3a5e5
commit 62ddce6804
5 files changed
+196

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-12
---
# Model display aliases
In Studio's model service settings, each selected model has an optional display
alias beneath its full model ID. Save the connection to apply it. Leave the alias
empty to show the model ID again. Revert discards unsaved changes with the rest of
the connection form.
The chat model picker and its active-model button show the alias. The picker keeps
the full model ID below it and searches both names. Duplicate aliases are allowed:
the provider and raw model ID still identify the selection.
Aliases are scoped to one provider entry and one exact model ID, including case
and any slash in the model ID. They do not change provider requests, defaults,
role assignments, saved conversation references, or model availability. Editing
only aliases does not rebuild or interrupt a running conversation.
The user configuration stores them in the provider's `model_aliases` map:
```toml
[[providers]]
name = "local"
kind = "openai"
base_url = "http://localhost:11434/v1"
models = ["vendor/long-model-id"]
model_aliases = { "vendor/long-model-id" = "Short name" }
```
Aliases use the display-name limit of 64 Unicode characters and cannot contain
control characters. Unselecting a model retains its stored alias for later use;
clearing a selected model's alias removes it. Clients that omit `modelAliases`
when editing a provider preserve existing aliases.
Resolver-backed remote panes use their resolver's catalog. This machine's local
aliases are never overlaid onto that catalog. Removing fetched models is a
separate part of [#11774](https://github.com/esengine/DeepSeek-Reasonix/issues/11774).
+1
View File
@@ -93,6 +93,7 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
| Feature | Status | Studio location |
| --- | --- | --- |
| Per-model display aliases | Have | Model service settings and chat picker; [configuration and scope](MODEL_DISPLAY_ALIASES.md); #11774 |
| Icon rail with three display modes | Have | #12377, #12428; sidebar footer entries appear only when icon navigation is hidden (#12478) |
| Usage, feedback, account and settings footer / 用量、反馈、账号与设置底部入口 | Have differently | `ui/Nav.tsx` when navigation is visible; `ui/Sidebar.tsx` otherwise, including the narrow-window drawer. 导航可见时使用图标栏入口,隐藏时保留侧栏入口 (#12478) |
| Completed-unread marker on sessions | Have | #12218 |
@@ -0,0 +1,56 @@
package boot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"reasonix/internal/base/netclient"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
)
func TestModelAliasDoesNotChangeBuildIdentityOrWireModel(t *testing.T) {
gotModel := make(chan string, 2)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
gotModel <- body.Model
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
}))
defer upstream.Close()
cfg := &config.Config{Providers: []config.ProviderEntry{{Name: "relay", Kind: "openai", BaseURL: upstream.URL, Models: []string{"vendor/long-v1"}}}}
entry, _ := cfg.ResolveModel("relay/vendor/long-v1")
before := ResolveProviderBuildIdentity(entry, netclient.ProxySpec{}, nil)
for _, aliases := range []map[string]string{nil, {"vendor/long-v1": "Short"}} {
if err := cfg.Providers[0].SetModelAliases(aliases); err != nil {
t.Fatal(err)
}
entry, _ = cfg.ResolveModel("relay/vendor/long-v1")
if got := ResolveProviderBuildIdentity(entry, netclient.ProxySpec{}, nil); got != before {
t.Fatal("display alias changed provider build identity")
}
p, err := NewProviderWithProxy(entry, netclient.ProxySpec{})
if err != nil {
t.Fatal(err)
}
stream, err := p.Stream(t.Context(), provider.Request{})
if err != nil {
t.Fatal(err)
}
for chunk := range stream {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
}
if got := <-gotModel; got != "vendor/long-v1" {
t.Fatalf("wire model = %q", got)
}
}
}
@@ -0,0 +1,59 @@
package serve
import (
"net/http"
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/contract/config"
)
func TestModelAliasFailedSaveDoesNotPublishAndCanRetry(t *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
t.Skip("requires enforced directory write permissions")
}
srv := newRichProviderServer(t)
path := config.UserConfigPath()
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(path)
if err := os.Chmod(dir, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
body := `{"name":"rich","models":["alpha","beta"],"default":"alpha","vision":[],"modelAliases":{"alpha":"Short"}}`
resp := postProvider(t, srv.URL, "/providers/edit", body)
resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("failed save = %d", resp.StatusCode)
}
after, err := os.ReadFile(path)
if err != nil || string(before) != string(after) {
t.Fatalf("failed save changed config: %v", err)
}
var got struct {
Models []modelEntry `json:"models"`
}
getJSON(t, srv.URL+"/models", &got)
for _, m := range got.Models {
if m.ModelAlias != "" {
t.Fatalf("failed alias was published: %+v", m)
}
}
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatal(err)
}
resp = postProvider(t, srv.URL, "/providers/edit", body)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("retry = %d", resp.StatusCode)
}
p, _ := config.LoadForEdit(path).Provider("rich")
if p.ModelAlias("alpha") != "Short" {
t.Fatal("successful retry did not persist alias")
}
}
@@ -0,0 +1,38 @@
package serve
import (
"net/http/httptest"
"reasonix/internal/contract/config"
"testing"
)
func TestModelAliasesNeverOverlayRemoteCatalog(t *testing.T) {
writeServeModelConfig(t)
closeSharedCatalogsOnCleanup(t)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "home", Kind: "openai", BaseURL: "https://local.invalid", Models: []string{"chat-a", "chat-b"}, ModelAliases: map[string]string{"chat-a": "Local alias", "chat-b": "Local alias"}})
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
h := NewHub(HubOptions{ProviderResolver: homeResolver()})
defer h.Shutdown()
rt, err := h.Open(t.Context(), OpenRequest{Root: t.TempDir()})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
var got struct {
Current string `json:"current"`
Models []modelEntry `json:"models"`
}
getJSON(t, srv.URL+"/rt/"+rt.ID+"/models", &got)
if got.Current != "home/chat-b" || len(got.Models) != 2 {
t.Fatalf("remote catalog changed: %+v", got)
}
for _, m := range got.Models {
if m.ModelAlias != "" {
t.Fatalf("local alias leaked into remote catalog: %+v", m)
}
}
}