Merge commit 'refs/pr12805' into takeover/12805

This commit is contained in:
yhh committed 2026-10-12 06:01:33 -07:00
commit 49d8c158bc
30 files changed
+672 -18

No files matched your search

@@ -196,6 +196,7 @@ export const EN_KERNEL: Record<string, string> = {
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
"显示别名必须使用已选择模型的完整 ID": "Display aliases must use the exact IDs of selected models",
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
@@ -2,6 +2,9 @@
// alone — one catalogue is still the model, and this file is read as its
// continuation: same keying by Chinese source text, same grouping by screen.
export const EN_SETTINGS: Record<string, string> = {
"{name} 的显示别名": "Display alias for {name}",
"显示别名(留空使用模型 ID)": "Display alias (empty uses model ID)",
"仅用于显示,不改变请求中的模型 ID": "Display only; the model ID sent in requests is unchanged",
"浏览器标签": "Browser tabs",
"关闭其他浏览器标签": "Close other browser tabs",
"关闭全部浏览器标签": "Close all browser tabs",
+1
View File
@@ -66,6 +66,7 @@ const SAID: Record<string, string> = {
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
"provider.model_alias_unlisted": "显示别名必须使用已选择模型的完整 ID",
// ── 来源:这个协议做不到 ─────────────────────────────────────────
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
+1
View File
@@ -49,6 +49,7 @@ export interface ModelEntry {
// What the user renamed provider to; provider stays the identity.
displayName?: string;
model: string;
modelAlias?: string;
kind?: string;
active?: boolean;
default?: boolean;
@@ -9,6 +9,7 @@ export interface ProviderEntry {
kind: string;
baseUrl: string;
models: string[];
modelAliases?: Record<string, string>;
default: string;
hasKey: boolean;
keyEnv?: string;
@@ -215,6 +216,8 @@ export interface ProviderModelCheckRequest {
// Changing a source that already exists: everything else on the entry stays.
export interface ProviderEdit {
name: string;
// Omitted preserves aliases; blank or missing values reset listed models.
modelAliases?: Record<string, string>;
// Omitted leaves the wire format alone.
kind?: string;
baseUrl?: string;
@@ -3674,6 +3674,7 @@ body { font-size: 14px; line-height: 1.75; }
.addp .fields select,
.addp .fields textarea,
.mline .nm { font-size: var(--fs-ui); }
.mline .malias { min-width: 0; width: 100%; box-sizing: border-box; font-size: var(--fs-ui); }
.prefs-main { padding-top: 28px; }
.prefs-col { max-width: 820px; }
.grp { padding: 20px 22px 18px; }
@@ -10,6 +10,16 @@ import { draftKey } from "./drafts";
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
it("labels the active model with its alias while keeping the raw reference in details", async () => {
const port = new MockPort();
vi.spyOn(port, "models").mockResolvedValue([
{ ref: "relay/vendor/long-v1", provider: "relay", model: "vendor/long-v1", modelAlias: "Short" },
]);
draw({ port, st: status({ modelRef: "relay/vendor/long-v1" }) });
const trigger = await screen.findByRole("button", { name: /Short/ });
expect(trigger.title).toBe("relay/vendor/long-v1");
});
// The workspace's git answer, the way /workspace/git words it. Tests that name
// an override get it; the rest get a plain repository on main.
const git = (over: Partial<WorkspaceGit> = {}): WorkspaceGit =>
+2 -1
View File
@@ -355,7 +355,8 @@ export function Composer({ port, status, running, quote, restore, focus, onSubmi
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const forcedThinking = forcesThinkingFor(models, status?.modelRef);
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
const modelLb = models.find((m) => m.ref === status?.modelRef)?.modelAlias?.trim()
|| (status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—");
// A model switch rebuilds the runtime kernel-side; other controls here may
// land at once. Each click needs its own pending state: greying the whole
// shelf says nothing can change while only one control waits on the kernel.
@@ -41,6 +41,7 @@ export function EditConn({
const [apiKey, setApiKey] = useState("");
const [models, setModels] = useState<string[]>(seededModels);
const [picked, setPicked] = useState<string[]>(entry.models);
const [aliases, setAliases] = useState<Record<string, string>>(entry.modelAliases ?? {});
const [vision, setVision] = useState<string[]>(seededVision);
const [visionSettable, setVisionSettable] = useState<string[] | undefined>(
entry.visionSettable ? [...new Set([...entry.visionSettable, ...(initialCheck?.vision ?? [])])] : undefined,
@@ -251,6 +252,7 @@ export function EditConn({
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
models: picked,
modelAliases: Object.fromEntries(Object.entries(aliases).filter(([m, value]) => picked.includes(m) && value.trim()).map(([m, value]) => [m, value.trim()])),
default: picked.includes(def) ? def : picked[0] ?? "",
vision: vision.filter((m) => picked.includes(m)),
contextWindow: Number(win.replace(/\D/g, "")) || 0,
@@ -413,6 +415,8 @@ export function EditConn({
facts={facts}
visionLocked={visionLocked}
onCheck={checkModel}
aliases={aliases}
onAlias={(m, value) => setAliases((current) => ({ ...current, [m]: value }))}
checkDisabled={busy !== "" || testing}
onToggle={(m) => toggle(picked, setPicked, m)}
onVision={(m) => toggle(vision, setVision, m)}
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import type { ModelEntry, ProviderEntry } from "../port/port";
import type { Port } from "./Providers";
import { EditConn } from "./EditConn";
import { ModelChoice } from "./ModelChoice";
import { Picker } from "./Menu";
import { modelMenu } from "./modelmenu";
afterEach(cleanup);
it("shows model aliases while search and selection retain exact provider/model identities", async () => {
const models = [
{ ref: "local/vendor/long-v1", provider: "local", model: "vendor/long-v1", modelAlias: "Short" },
{ ref: "other/vendor/long-v1", provider: "other", model: "vendor/long-v1", modelAlias: "Other alias" },
{ ref: "local/second", provider: "local", model: "second", modelAlias: "Short" },
] as ModelEntry[];
const onPick = vi.fn();
render(<Picker label="Models" items={modelMenu(models, [], "local/second")} current="local/second" onPick={onPick} place="top" searchAlways />);
await userEvent.click(screen.getByRole("button", { name: "Models" }));
expect(screen.getAllByRole("menuitem")[0].getAttribute("data-value")).toBe("local/second");
expect(screen.getAllByRole("menuitem")[0].hasAttribute("data-on")).toBe(true);
expect(screen.getAllByRole("menuitem", { name: /Short/ })).toHaveLength(2);
const search = screen.getByRole("textbox");
await userEvent.type(search, "long-v1");
expect(screen.getByRole("menuitem", { name: /Short/ })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /Other alias/ })).toBeTruthy();
expect(document.querySelector('[data-value="local/second"]')).toBeNull();
await userEvent.clear(search);
await userEvent.type(search, "Other alias{enter}");
expect(onPick).toHaveBeenCalledWith("other/vendor/long-v1");
});
it("edits aliases alongside model settings, preserves them across fetch, and retries failed saves", async () => {
const editProvider = vi.fn().mockRejectedValueOnce(new Error("disk unavailable")).mockResolvedValue(undefined);
const onDone = vi.fn();
const entry = {
name: "local", kind: "openai", baseUrl: "https://local.invalid/v1",
models: ["vendor/long-v1"], default: "vendor/long-v1", modelAliases: { "vendor/long-v1": "Old" },
hasKey: true, inUse: false, preset: false,
} as ProviderEntry;
const port = {
editProvider, protocols: vi.fn(async () => []),
checkProvider: vi.fn(async () => ({ ok: true, models: ["vendor/long-v1", "second"] })),
} as unknown as Port;
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={onDone} onRevert={() => {}} />);
const alias = screen.getByRole("textbox", { name: "vendor/long-v1 的显示别名" });
expect((alias as HTMLInputElement).value).toBe("Old");
await userEvent.clear(alias);
await userEvent.type(alias, "Short");
const search = screen.getByRole("searchbox", { name: "搜索或添加模型" });
await userEvent.type(search, "Short");
expect(screen.getByRole("textbox", { name: "vendor/long-v1 的显示别名" })).toBe(alias);
await userEvent.clear(search);
await userEvent.click(screen.getByRole("button", { name: "从服务商读取可用模型" }));
expect((alias as HTMLInputElement).value).toBe("Short");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await screen.findByText("保存失败");
expect(onDone).not.toHaveBeenCalled();
expect(entry.modelAliases).toEqual({ "vendor/long-v1": "Old" });
expect((alias as HTMLInputElement).value).toBe("Short");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(onDone).toHaveBeenCalledOnce());
expect(editProvider).toHaveBeenLastCalledWith(expect.objectContaining({
name: "local", models: ["vendor/long-v1"], default: "vendor/long-v1", modelAliases: { "vendor/long-v1": "Short" },
}));
await userEvent.clear(alias);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(editProvider).toHaveBeenLastCalledWith(expect.objectContaining({ modelAliases: {} }));
});
it.each(["enter", "button"])("allows adding an exact wire ID that matches another model's alias via %s", async (method) => {
const editProvider = vi.fn(async () => {});
const entry: ProviderEntry = {
name: "local", kind: "openai", baseUrl: "https://local.invalid/v1", models: ["vendor/long-v1"],
default: "vendor/long-v1", modelAliases: { "vendor/long-v1": "beta" }, hasKey: true, inUse: false, preset: false,
};
const port = { editProvider, protocols: vi.fn(async () => []) } as unknown as Port;
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={() => {}} onRevert={() => {}} />);
const search = screen.getByRole("searchbox", { name: "搜索或添加模型" });
await userEvent.type(search, "beta");
expect(screen.getByText("vendor/long-v1")).toBeTruthy();
const add = screen.getByRole("button", { name: /添加未列出的模型「beta」/ });
if (method === "enter") await userEvent.type(search, "{enter}");
else await userEvent.click(add);
expect(screen.getByRole("checkbox", { name: "选用 beta" }).getAttribute("aria-checked")).toBe("true");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(editProvider).toHaveBeenCalledWith(expect.objectContaining({
models: ["vendor/long-v1", "beta"], default: "vendor/long-v1", modelAliases: { "vendor/long-v1": "beta" },
}));
});
it("keeps an unchecked alias when its model is selected again", async () => {
const editProvider = vi.fn(async () => {});
const entry: ProviderEntry = {
name: "local", kind: "openai", baseUrl: "https://local.invalid/v1", models: ["raw-model", "second"],
default: "raw-model", hasKey: true, inUse: false, preset: false,
};
const port = { editProvider, protocols: vi.fn(async () => []) } as unknown as Port;
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={() => {}} onRevert={() => {}} />);
const alias = screen.getByRole("textbox", { name: "raw-model 的显示别名" }) as HTMLInputElement;
expect(alias.value).toBe("");
await userEvent.type(alias, "Short");
await userEvent.click(screen.getByRole("checkbox", { name: "选用 raw-model" }));
expect(alias.disabled).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "选用 raw-model" }));
expect(alias.value).toBe("Short");
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(editProvider).toHaveBeenCalledWith(expect.objectContaining({ modelAliases: { "raw-model": "Short" } }));
});
it("treats special model IDs as alias data without reading object prototypes", async () => {
const onAlias = vi.fn();
render(<ModelChoice models={["__proto__", "constructor"]} picked={["__proto__", "constructor"]} vision={[]}
aliases={{}} onAlias={onAlias} onToggle={() => {}} onAdd={() => {}} />);
const field = screen.getByRole("textbox", { name: "__proto__ 的显示别名" }) as HTMLInputElement;
expect(field.value).toBe("");
await userEvent.type(field, "X");
expect(onAlias).toHaveBeenCalledWith("__proto__", "X");
});
+12 -4
View File
@@ -37,6 +37,8 @@ export function clearModelCheckFacts(facts: Record<string, ModelFact>): Record<s
interface Props {
// Everything on offer: what the endpoint reported, plus anything added here.
models: string[];
aliases?: Record<string, string>;
onAlias?: (m: string, value: string) => void;
picked: string[];
vision: string[];
// Absent where the flow has no default to set — adding a source takes the
@@ -62,11 +64,12 @@ interface Props {
}
export function ModelChoice({
models, picked, vision, def, onDefault, onVision, visionLocked, facts, onCheck, checkDisabled, onToggle, onAdd,
models, aliases, onAlias, picked, vision, def, onDefault, onVision, visionLocked, facts, onCheck, checkDisabled, onToggle, onAdd,
onFetch, fetching, fetchDisabled, fetchFail,
}: Props) {
const [q, setQ] = useState("");
const query = q.trim().toLowerCase();
const aliasFor = (m: string) => aliases && Object.hasOwn(aliases, m) ? aliases[m] : "";
const on = useMemo(() => new Set(picked), [picked]);
// Enabled first, and deliberately keyed on the list alone: re-sorting on every
@@ -77,10 +80,9 @@ export function ModelChoice({
[models],
);
const hits = query ? order.filter((m) => m.toLowerCase().includes(query)) : order;
const hits = query ? order.filter((m) => `${m} ${aliasFor(m)}`.toLowerCase().includes(query)) : order;
const [shown, hidden] = cap(hits, on);
// Only when nothing already carries the name: offering to add a model that is
// sitting three rows down is how a list grows two of everything.
// Display aliases aid search but cannot reserve another model's wire ID.
const naming = q.trim() !== "" && !models.some((m) => m.toLowerCase() === query);
const add = () => {
@@ -131,6 +133,12 @@ export function ModelChoice({
</button>
<span className="mid">
<span className="nm">{m}</span>
{onAlias && <input className="malias" value={aliasFor(m)} spellCheck={false}
data-action="provider.draft" data-value="model-alias" data-target={m}
aria-label={t("{name} 的显示别名", { name: m })}
placeholder={t("显示别名(留空使用模型 ID)")}
title={t("仅用于显示,不改变请求中的模型 ID")}
disabled={!on.has(m)} onChange={(e) => onAlias(m, e.target.value)} />}
{facts?.[m] && <ModelEvidence fact={facts[m]} />}
</span>
<span className="mops">
+2 -1
View File
@@ -19,7 +19,8 @@ export function modelMenu(models: ModelEntry[], order: readonly string[] = [], c
for (const m of a.byKind[kind]) {
out.push({
value: m.ref,
label: m.model,
label: m.modelAlias?.trim() || m.model,
desc: m.modelAlias?.trim() ? m.model : undefined,
meta: t(KIND_LABEL[kind] ?? kind),
badge: m.vision ? t("读图") : undefined,
mono,
+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)
}
}
}
@@ -95,6 +95,7 @@ func providerFingerprint(e *config.ProviderEntry, proxy netclient.ProxySpec) str
delete(extra, "effort")
entry := *e
entry.Effort = ""
entry.ModelAliases = nil
payload := struct {
Entry config.ProviderEntry
APIKey string
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"errors"
"fmt"
"maps"
"slices"
"strings"
)
var ErrModelAliasUnlisted = errors.New("model alias does not name a selected model")
// ModelAlias is display metadata for one exact model ID, never a lookup alias.
func (e *ProviderEntry) ModelAlias(model string) string {
return strings.TrimSpace(e.ModelAliases[model])
}
// SetModelAliases replaces aliases for listed models while retaining unlisted
// models' metadata. Omitted or blank values clear a listed model's alias.
func (e *ProviderEntry) SetModelAliases(aliases map[string]string) error {
models := e.ModelList()
next := maps.Clone(e.ModelAliases)
if next == nil {
next = map[string]string{}
}
for model, value := range aliases {
if !slices.Contains(models, model) {
return fmt.Errorf("%q: %w", model, ErrModelAliasUnlisted)
}
label, err := NormalizeProviderDisplayName(value)
if err != nil {
return err
}
next[model] = label
}
for _, model := range models {
if strings.TrimSpace(aliases[model]) == "" {
delete(next, model)
}
}
if len(next) == 0 {
next = nil
}
e.ModelAliases = next
return nil
}
func renderModelAliases(b *strings.Builder, aliases map[string]string) {
if len(aliases) > 0 {
fmt.Fprintf(b, "model_aliases = %s\n", renderStringMap(aliases))
}
}
@@ -0,0 +1,85 @@
package config
import (
"errors"
"maps"
"os"
"strings"
"testing"
)
func TestModelAliasConfigSurvivesBothRenderers(t *testing.T) {
path := writeDisplayNameConfig(t)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
raw = []byte(strings.Replace(string(raw), `models = ["gpt-5", "gpt-5-mini"]`, `models = ["gpt-5", "gpt-5-mini"]`+"\n"+`model_aliases = { "gpt-5" = "简短名称", "gpt-5-mini" = "简短名称" }`, 1))
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
edit := LoadForEdit(path)
p, _ := edit.Provider("relay")
for _, render := range []func(*strings.Builder, ProviderEntry){renderProviderEntryPlain, renderProviderEntryAnnotated} {
var b strings.Builder
render(&b, *p)
if !strings.Contains(b.String(), `model_aliases = { gpt-5 = "简短名称"`) {
t.Fatalf("render lost model aliases:\n%s", b.String())
}
}
if err := edit.SaveTo(path); err != nil {
t.Fatal(err)
}
saved, _ := os.ReadFile(path)
if !strings.Contains(string(saved), "model_aliases") {
t.Fatalf("save lost aliases:\n%s", saved)
}
resolved, ok := LoadForEdit(path).ResolveModel("relay/gpt-5-mini")
if !ok || resolved.Model != "gpt-5-mini" || resolved.ModelAlias("gpt-5-mini") != "简短名称" {
t.Fatalf("wire model changed: %+v", resolved)
}
}
func TestModelAliasesAreExactAtomicAndIsolated(t *testing.T) {
e := ProviderEntry{Models: []string{"vendor/model", "Vendor/model"}, ModelAliases: map[string]string{"unlisted": "Retain"}}
if err := e.SetModelAliases(map[string]string{"vendor/model": " Same ", "Vendor/model": "Same"}); err != nil {
t.Fatal(err)
}
if e.ModelAlias("vendor/model") != "Same" || e.ModelAlias("Vendor/model") != "Same" || e.ModelAlias("vendor/Model") != "" {
t.Fatalf("aliases lost exact ID matching: %+v", e.ModelAliases)
}
before := maps.Clone(e.ModelAliases)
for _, tc := range []struct {
aliases map[string]string
want error
}{
{map[string]string{"vendor/model": "Changed", "ghost": "No"}, ErrModelAliasUnlisted},
{map[string]string{"vendor/model": "Changed", "Vendor/model": "a\tb"}, ErrProviderDisplayNameInvalid},
{map[string]string{"vendor/model": strings.Repeat("名", 65)}, ErrProviderDisplayNameTooLong},
} {
if err := e.SetModelAliases(tc.aliases); !errors.Is(err, tc.want) || !maps.Equal(e.ModelAliases, before) {
t.Fatalf("refused edit = %v, aliases = %+v", err, e.ModelAliases)
}
}
cp := cloneProviderEntry(e)
if err := cp.SetModelAliases(map[string]string{"vendor/model": strings.Repeat("名", 64)}); err != nil {
t.Fatal(err)
}
cp.ModelAliases["unlisted"] = "Changed"
if !maps.Equal(e.ModelAliases, before) {
t.Fatal("editing a cloned provider changed the original")
}
if err := e.SetModelAliases(map[string]string{"vendor/model": " "}); err != nil {
t.Fatal(err)
}
if !maps.Equal(e.ModelAliases, map[string]string{"unlisted": "Retain"}) {
t.Fatalf("reset did not preserve unlisted metadata: %+v", e.ModelAliases)
}
}
func TestWireModelMigrationCarriesDisplayMetadata(t *testing.T) {
e := ProviderEntry{Models: []string{"old", "new"}, ModelAliases: map[string]string{"old": "Old alias", "new": "Chosen alias"}}
if !renameProviderModel(&e, "old", "new") || len(e.ModelAliases) != 1 || e.ModelAlias("new") != "Chosen alias" {
t.Fatalf("wire migration lost the survivor's alias: %+v", e)
}
}
+7 -6
View File
@@ -15,12 +15,13 @@ type ProviderEntry struct {
DisplayName string `toml:"display_name"` // what the UI calls this entry; Name stays the identity refs point at
Kind string `toml:"kind"`
BaseURL string `toml:"base_url"`
ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics
RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI
Model string `toml:"model"` // a single model (back-compat)
Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
Default string `toml:"default"` // default model when Models is set (else Models[0])
ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics
RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI
Model string `toml:"model"` // a single model (back-compat)
Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
ModelAliases map[string]string `toml:"model_aliases"` // display only; keys are exact model IDs within this provider
ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
Default string `toml:"default"` // default model when Models is set (else Models[0])
APIKeyEnv string `toml:"api_key_env"`
PresetID string `toml:"preset_id"` // curated preset provenance: UI dedupe, and the vetting a wire contract reads.
PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations.
@@ -29,6 +29,9 @@ func renameProviderModel(p *ProviderEntry, from, to string) bool {
if renameMapKey(p.ModelOverrides, from, to) {
changed = true
}
if renameMapKey(p.ModelAliases, from, to) {
changed = true
}
return changed
}
+1 -1
View File
@@ -812,7 +812,7 @@ func cloneProviderEntry(e ProviderEntry) ProviderEntry {
e.Models = append([]string(nil), e.Models...)
e.VisionModels = append([]string(nil), e.VisionModels...)
e.SupportedEfforts = append([]string(nil), e.SupportedEfforts...)
e.Headers = cloneStringMap(e.Headers)
e.Headers, e.ModelAliases = cloneStringMap(e.Headers), cloneStringMap(e.ModelAliases)
e.ExtraBody = cloneAnyMap(e.ExtraBody)
e.Price = clonePricing(e.Price)
e.Prices = clonePricingMap(e.Prices)
@@ -31,6 +31,7 @@ func renderProviderEntryAnnotated(b *strings.Builder, p ProviderEntry) {
fmt.Fprintf(b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL)
}
renderProviderIdentity(b, p.APIKeyEnv, p.DisplayName)
renderModelAliases(b, p.ModelAliases)
if p.PresetID != "" {
fmt.Fprintf(b, "preset_id = %q # curated preset identity; settings UI uses it to avoid duplicate installs\n", p.PresetID)
}
@@ -146,6 +147,7 @@ func renderProviderEntryPlain(b *strings.Builder, p ProviderEntry) {
fmt.Fprintf(b, "models_url = %q\n", p.ModelsURL)
}
renderProviderIdentity(b, p.APIKeyEnv, p.DisplayName)
renderModelAliases(b, p.ModelAliases)
if p.PresetID != "" {
fmt.Fprintf(b, "preset_id = %q\n", p.PresetID)
}
+1
View File
@@ -59,6 +59,7 @@ func capabilitiesFor(cfg *config.Config, p *config.ProviderEntry, model string,
if cfg == nil || p == nil {
return
}
into.ModelAlias = p.ModelAlias(model)
resolved, ok := cfg.ResolveModel(p.Name + "/" + model)
if !ok || resolved.Name != p.Name || resolved.Model != model {
into.Vendor = vendorOf(p.BaseURL)
+6 -1
View File
@@ -55,7 +55,8 @@ func (s *Server) editProvider(w http.ResponseWriter, r *http.Request) {
ModelEfforts *map[string]modelEffortView `json:"modelEfforts"`
// Per-model window and output cap, with the same listed-models-are-the-
// whole-answer rule; a model sent empty inherits the connection's.
ModelLimits *map[string]modelLimitsView `json:"modelLimits"`
ModelLimits *map[string]modelLimitsView `json:"modelLimits"`
ModelAliases *map[string]string `json:"modelAliases"`
}
if !decodeProviderBody(w, r, &body) {
return
@@ -87,6 +88,10 @@ func (s *Server) editProvider(w http.ResponseWriter, r *http.Request) {
entry.Models = models
entry.Model = ""
entry.Default = def
if bad := applyModelAliases(entry, body.ModelAliases); bad != nil {
refuse(w, http.StatusBadRequest, bad.code, bad.message, bad.detail)
return
}
applyVisionSelection(entry, trimmedNonEmpty(body.Vision))
if !applyNumericFields(w, entry, body.ContextWindow, body.MaxOutputTokens, body.IdleTimeoutSeconds) {
@@ -0,0 +1,23 @@
package serve
import (
"errors"
"reasonix/internal/contract/config"
)
func applyModelAliases(entry *config.ProviderEntry, sent *map[string]string) *editRefusal {
if sent == nil {
return nil
}
err := entry.SetModelAliases(*sent)
switch {
case errors.Is(err, config.ErrModelAliasUnlisted):
return &editRefusal{"provider.model_alias_unlisted", "model aliases must name selected models by their exact IDs", nil}
case errors.Is(err, config.ErrProviderDisplayNameTooLong):
return &editRefusal{"provider.display_name_too_long", "the display name is too long", map[string]any{"max": config.MaxProviderDisplayNameRunes}}
case errors.Is(err, config.ErrProviderDisplayNameInvalid):
return &editRefusal{"provider.display_name_invalid", "the display name contains a control character", nil}
}
return nil
}
@@ -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)
}
}
}
@@ -0,0 +1,124 @@
package serve
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func TestModelAliasEditRoundTripKeepsModelIdentity(t *testing.T) {
srv := newRichProviderServerAs(t, func(c control.SessionAPI) control.SessionAPI { return midTurn{c} })
for _, aliases := range []string{`{"alpha":" Short ","beta":"Short"}`, `{"alpha":" "}`} {
resp := postProvider(t, srv.URL, "/providers/edit", `{"name":"rich","models":["alpha","beta"],"default":"alpha","vision":[],"modelAliases":`+aliases+`}`)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("alias-only edit attempted a rebuild: status %d", resp.StatusCode)
}
var got struct {
Current string `json:"current"`
Default string `json:"default"`
Models []map[string]any `json:"models"`
}
getJSON(t, srv.URL+"/models", &got)
if got.Current != "rich/alpha" || got.Default != "rich/alpha" {
t.Fatalf("alias edit changed model identity: %+v", got)
}
want := "Short"
if strings.Contains(aliases, `" "`) {
want = ""
}
for _, m := range got.Models {
alias, _ := m["modelAlias"].(string)
if alias != want || m["ref"] != "rich/"+m["model"].(string) {
t.Fatalf("model projection = %+v, want alias %q with raw ref", m, want)
}
}
var providers []struct {
ModelAliases map[string]string `json:"modelAliases"`
}
getJSON(t, srv.URL+"/providers", &providers)
if len(providers) != 1 || providers[0].ModelAliases["alpha"] != want {
t.Fatalf("provider projection = %+v, want %q", providers, want)
}
cfg := config.LoadForEdit(config.UserConfigPath())
p, ok := cfg.ResolveModel("rich/alpha")
if !ok || p.Model != "alpha" || p.Default != "alpha" || cfg.DefaultModel != "rich/alpha" || p.PriceForModel("alpha") == nil {
t.Fatalf("alias disturbed the resolved entry: %+v", p)
}
if _, ok := cfg.ResolveModel("rich/Short"); ok {
t.Fatal("a display alias resolved as a wire model ID")
}
}
}
func TestModelAliasRefusalsLeaveConfigUnchanged(t *testing.T) {
srv := newRichProviderServer(t)
before, err := os.ReadFile(config.UserConfigPath())
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ aliases, code string }{
{`{"alpha":"Valid","other":"No"}`, "provider.model_alias_unlisted"},
{`{" alpha ":"No"}`, "provider.model_alias_unlisted"},
{`{"alpha":"a\nb"}`, "provider.display_name_invalid"},
{`{"alpha":"` + strings.Repeat("名", 65) + `"}`, "provider.display_name_too_long"},
} {
resp := postProvider(t, srv.URL, "/providers/edit", `{"name":"rich","models":["alpha","beta"],"default":"alpha","vision":[],"modelAliases":`+tc.aliases+`}`)
code := reasonCode(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest || code != tc.code {
t.Fatalf("%s: got %d %s, want 400 %s", tc.aliases, resp.StatusCode, code, tc.code)
}
}
after, _ := os.ReadFile(config.UserConfigPath())
if string(before) != string(after) {
t.Fatal("a refused alias edit rewrote the config")
}
}
func TestModelAliasesStayWithTheirProviderAndSurviveOlderClients(t *testing.T) {
srv := newRichProviderServer(t)
cfg := config.LoadForEdit(config.UserConfigPath())
source, _ := cfg.Provider("rich")
if err := source.SetModelAliases(map[string]string{"alpha": "First", "beta": "Retained"}); err != nil {
t.Fatal(err)
}
other := *source
other.Name, other.BaseURL, other.ModelAliases = "other", "https://other.invalid/v1", map[string]string{"alpha": "Second"}
cfg.Providers = append(cfg.Providers, other)
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
resp := postProvider(t, srv.URL, "/providers/edit", `{"name":"rich","models":["alpha","beta"],"default":"alpha","vision":[]}`)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("older client edit = %d", resp.StatusCode)
}
var got struct {
Models []modelEntry `json:"models"`
}
getJSON(t, srv.URL+"/models", &got)
aliases := map[string]string{}
for _, m := range got.Models {
aliases[m.Ref] = m.ModelAlias
}
if aliases["rich/alpha"] != "First" || aliases["other/alpha"] != "Second" || aliases["rich/beta"] != "Retained" {
t.Fatalf("aliases crossed identities: %+v", aliases)
}
}
func TestModelAliasEditStillRequiresProviderGrant(t *testing.T) {
s := newProviderEditServer(t)
req := httptest.NewRequest(http.MethodPost, "/providers/edit", strings.NewReader(`{"name":"existing","models":["model-a"],"modelAliases":{"model-a":"Short"}}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
operatorHandler(s).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "provider.editing_disabled") {
t.Fatalf("ungranted alias edit = %d %s", rec.Code, rec.Body.String())
}
}
+6 -4
View File
@@ -63,10 +63,11 @@ var providerNameRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
type providerView struct {
Name string `json:"name"`
// DisplayName is the label a person gave this entry; empty is none set.
DisplayName string `json:"displayName,omitempty"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
DisplayName string `json:"displayName,omitempty"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
ModelAliases map[string]string `json:"modelAliases,omitempty"`
// VisionModels is which of them read images, so the form shows the current
// answer instead of asking the user to remember it.
VisionModels []string `json:"visionModels"`
@@ -155,6 +156,7 @@ func (s *Server) providers(w http.ResponseWriter, _ *http.Request) {
Kind: strings.ToLower(strings.TrimSpace(p.Kind)),
BaseURL: p.BaseURL,
Models: nonNilStrings(p.ChatModelList()),
ModelAliases: p.ModelAliases,
VisionModels: nonNilStrings(visionModelsOf(cfg, p)),
CanSetVision: len(settable) > 0 || config.CanConfigureVision(p),
VisionSettable: nonNilStrings(settable),
+1
View File
@@ -256,6 +256,7 @@ type modelEntry struct {
// DisplayName labels Provider on screen; Ref and Provider stay the identity.
DisplayName string `json:"displayName,omitempty"`
Model string `json:"model"`
ModelAlias string `json:"modelAlias,omitempty"`
Kind string `json:"kind,omitempty"`
// What this model produces, projected from the protocol table so a chooser
// filters on a declaration rather than on a kind's spelling.