mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12805' into takeover/12805
This commit is contained in:
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",
|
||||
|
||||
@@ -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": "该协议不发送思考参数,启用后不会生效",
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user