diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 68857956f3..3d9fd95906 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -397,7 +397,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
await picker.getByRole('button', { name: '取消全选' }).click()
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
- await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
+ await picker.getByRole('checkbox', { name: 'GPT-6 Astra', exact: true }).check()
await picker.getByRole('button', { name: '添加所选' }).click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await image.isChecked()).toBe(true)
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index 5770cd17f6..8dcae71776 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 8bced338559ae1609ce3816f3ea7e760dcce2da7
-README.zh.md: ebb321842aa5a68b9cd3aa7355afdb249c71ee6d
+README.md: e0accf787aba9d2857887773b4f1a3d703fc1fc1
+README.zh.md: 39b443c2293a9da0f7a82bc70d10539a2d153aa8
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index 8bced33855..e0accf787a 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -45,7 +45,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid
### Adding and deleting providers
-**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its name, falling back to its id when no name is supplied. Rows stay on one line; hovering reveals the full text when it is truncated. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
### First-run dialogs
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index ebb321842a..39b443c229 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -45,7 +45,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会
### 新增与删除提供商
-**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
+**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项显示模型名称,未提供名称时回退到 id。每行保持单行,文字截断时可悬停查看完整内容。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
### 首次运行弹窗
diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
index e8ce776875..cfab83e8d9 100644
--- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
@@ -447,10 +447,9 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
checked={picked.has(candidate.id)}
onChange={() => { toggle(candidate.id) }}
/>
- {/* The id alone: it is the string adoption writes, and the
- capacities the endpoint reported are adopted with it and
- editable in the row that appears. */}
- {candidate.id}
+
+ {candidate.name ?? candidate.id}
+
))}
diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
index d39e807876..a21446f6ff 100644
--- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css
+++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
@@ -705,11 +705,13 @@ select.input {
cursor: pointer;
}
-.candidateId {
+.candidateName {
flex: 1 1 auto;
- font-family: var(--ds-font-family-code);
+ min-width: 0;
font-size: 13px;
- overflow-wrap: anywhere;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.candidateEmpty {
diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
index 131f3c6829..5140c7a427 100644
--- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
@@ -609,6 +609,8 @@ describe('endpoint interrogation', () => {
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
+ expect(screen.getByRole('checkbox', { name: 'Fresh' }).checked).toBe(true)
+ expect(screen.queryByRole('checkbox', { name: 'fresh' })).toBeNull()
// The already-configured row starts unchecked; the new one starts checked.
const boxes = [...document.querySelectorAll('input[type="checkbox"]')]
expect(boxes.map(box => box.checked)).toEqual([false, true])
@@ -748,6 +750,8 @@ describe('endpoint interrogation', () => {
fireEvent.click(screen.getByText(en.fetchModels))
const dialog = await screen.findByRole('dialog')
const search = screen.getByLabelText(en.fetchSearch)
+ expect(dialog.textContent).toContain('Beta Display')
+ expect(dialog.textContent).not.toContain('opaque-id')
expect([...dialog.querySelectorAll('input[type="checkbox"]')]
.map(box => box.checked)).toEqual([true, true, true])
@@ -755,9 +759,12 @@ describe('endpoint interrogation', () => {
expect(dialog.textContent).toContain('alpha')
expect(dialog.textContent).not.toContain('opaque-id')
- // The display name is searchable even though adoption and the row use id.
fireEvent.change(search, { target: { value: 'beta' } })
- expect(dialog.textContent).toContain('opaque-id')
+ expect(dialog.textContent).toContain('Beta Display')
+ expect(dialog.textContent).not.toContain('alpha')
+
+ fireEvent.change(search, { target: { value: 'opaque' } })
+ expect(dialog.textContent).toContain('Beta Display')
expect(dialog.textContent).not.toContain('alpha')
fireEvent.click(within_(dialog, en.fetchDeselectAll))