feat(settings-models): one add entry with third-party and custom-API modes

The Models page offered two side-by-side buttons, "Add provider" and
"Add a custom provider", which asked a new user to classify their
provider by an implementation fact — whether it is in the installed
pi-ai catalog — before seeing anything. One "Add model provider" button
now opens one card whose segmented mode switch chooses between a
third-party model provider (the catalog select over the provider
editor) and a custom model API (the create form for a relay, a
self-hosted server, or any other OpenAI- or Anthropic-compatible
endpoint), with a one-line purpose beneath the switch. A panel mounts
the first time its mode is shown and stays mounted, hidden, while the
card is open, so switching modes discards neither draft.

A mode is offered only while its namespace is mounted and disabled
while it has nothing to offer, which keeps the old per-button
availability; with one mode the card shows that form alone. The
protocol pickers name each protocol by its product name while the
stored value stays the schema identifier, and an unnamed protocol shows
that identifier. The Chinese copy of this page calls a provider 提供商
throughout.

The e2e scenarios drive the single entry and its custom-API mode, and
the ARIA goldens that carry the page copy are re-recorded. The user
guide describes the one entry with fresh screenshots in both languages.
This commit is contained in:
Yichen Jiang committed 2026-09-19 15:14:52 +08:00
1 parent 6840878bf2
commit 8a320a276b
33 files changed
+583 -366

No files matched your search

@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
@@ -43,5 +43,4 @@
- button "添加模型"
- button "取消"
- button "保存"
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: openrouter
@@ -24,5 +24,4 @@
- button "编辑 acme-gateway": 编辑
- button "删除 acme-gateway": 删除
- alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks"
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
@@ -25,7 +25,7 @@
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: 提供方默认
- /placeholder: 提供商默认
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
@@ -59,5 +59,4 @@
- text: Acme 网关 自定义
- button "编辑 Acme 网关 (acme-gateway)": 编辑
- button "删除 Acme 网关 (acme-gateway)": 删除
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- status: 已保存 minimax-cn。
- list:
- listitem:
@@ -16,5 +16,4 @@
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
@@ -33,9 +33,9 @@
- text: https://gateway.acme.example/v1
- text: API 协议
- combobox "API 协议":
- option "openai-completions" [selected]
- option "openai-responses"
- option "anthropic-messages"
- option "OpenAI Chat Completions" [selected]
- option "OpenAI Responses"
- option "Anthropic Messages"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
@@ -62,5 +62,4 @@
- button "添加模型"
- button "取消"
- button "保存"
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: minimax-cn
@@ -19,5 +19,4 @@
- text: Acme Gateway 自定义
- button "编辑 Acme Gateway (acme-gateway)": 编辑
- button "删除 Acme Gateway (acme-gateway)": 删除
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,52 +8,57 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list
- text: 提供方
- combobox "提供方":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "baseten"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "qwen-token-plan-individual"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"
- tablist "添加方式":
- tab "第三方模型提供商" [selected]
- tab "自定义模型 API"
- paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。
- tabpanel "第三方模型提供商":
- text: 提供商
- combobox "提供商":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "baseten"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "qwen-token-plan-individual"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"
@@ -1,7 +1,7 @@
- dialog "选择要添加的模型":
- heading "选择要添加的模型" [level=2]
- button "关闭"
- paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。
- paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。
- searchbox "搜索模型"
- button "取消全选"
- list:
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
@@ -56,5 +56,4 @@
- button "添加模型"
- button "取消"
- button "保存"
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,7 +8,7 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
@@ -50,5 +50,4 @@
- button "添加模型"
- button "取消"
- button "保存"
- button "添加提供方"
- button "添加自定义提供方"
- button "添加模型提供商"
@@ -8,56 +8,61 @@
- button "打开配置文件"
- button "关闭"
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- paragraph: 填入各提供商的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek
- img "API 密钥缺失"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: 提供方
- combobox "提供方":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "baseten"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "qwen-token-plan-individual"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"
- tablist "添加方式":
- tab "第三方模型提供商" [selected]
- tab "自定义模型 API"
- paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。
- tabpanel "第三方模型提供商":
- text: 提供商
- combobox "提供商":
- option "amazon-bedrock"
- option "ant-ling"
- option "anthropic"
- option "azure-openai-responses"
- option "baseten"
- option "cerebras"
- option "cloudflare-ai-gateway"
- option "cloudflare-workers-ai"
- option "deepseek"
- option "fireworks"
- option "github-copilot"
- option "google"
- option "google-vertex"
- option "groq"
- option "huggingface"
- option "kimi-coding"
- option "minimax"
- option "minimax-cn" [selected]
- option "mistral"
- option "moonshotai"
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
- option "qwen-token-plan"
- option "qwen-token-plan-cn"
- option "qwen-token-plan-individual"
- option "together"
- option "vercel-ai-gateway"
- option "xai"
- option "xiaomi"
- option "xiaomi-token-plan-ams"
- option "xiaomi-token-plan-cn"
- option "xiaomi-token-plan-sgp"
- option "zai"
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"
@@ -54,19 +54,21 @@ describe('web e2e: repairs a stored provider after catalog drift', () => {
}
})
it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => {
it('shows the failed provider beside healthy providers and keeps the add action usable', async () => {
onTestFailed(() => saveFailureShot(page, 'models-settings-recovery'))
const dialog = page.getByRole('dialog', { name: '设置' })
expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1)
expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1)
expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).isEnabled()).toBe(true)
await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
await dialog.getByRole('button', { name: '添加提供方', exact: true }).click()
await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn')
await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click()
// Both modes stay offered beside a failed route: the card opens on the
// third-party mode with the custom-API segment enabled.
expect(await dialog.getByRole('tab', { name: '自定义模型 API' }).isEnabled()).toBe(true)
await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor()
expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}')
+28 -16
View File
@@ -1,5 +1,6 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, a blank key saves a
// wire — the one add card offers the dormant pi-ai catalog on its third-party
// mode and the create form on its custom-API mode, a blank key saves a
// reference-free profile for provider-native auth, and typing an API key later
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference. Each saved row
@@ -75,15 +76,19 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '模型' }).click()
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
// The dormant pi-ai adapter contributes its whole installed catalog; no
// provider is configured yet, so the page is one add button.
const add = dialog.getByRole('button', { name: '添加提供方' })
const add = dialog.getByRole('button', { name: '添加模型提供商' })
await add.waitFor({ timeout: 10_000 })
// The button enables once the dormant catalog lands in the join.
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
const pick = dialog.getByLabel('提供方')
// The card opens on the third-party mode, whose panel carries the select.
const modes = dialog.getByRole('tablist', { name: '添加方式' })
await modes.waitFor({ timeout: 10_000 })
expect(await modes.getByRole('tab', { name: '第三方模型提供商' }).getAttribute('aria-selected')).toBe('true')
const pick = dialog.getByLabel('提供商', { exact: true })
await pick.waitFor({ timeout: 10_000 })
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
const options = await pick.locator('option').allTextContents()
@@ -239,22 +244,29 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
it('declares a route the adapter does not ship', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
const dialog = page.getByRole('dialog', { name: '设置' })
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
await declare.click()
await dialog.getByLabel('Provider ID').fill('acme-gateway')
await dialog.getByLabel('显示名称').fill('Acme Gateway')
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
const add = dialog.getByRole('button', { name: '添加模型提供商' })
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
// The custom-API mode is the second segment of the one add card; its
// purpose line replaces the catalog one.
await dialog.getByRole('tab', { name: '自定义模型 API' }).click()
await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 })
// The third-party panel stays mounted, hidden, beside this one, so the
// form is addressed through its own panel.
const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' })
await custom.getByLabel('Provider ID').fill('acme-gateway')
await custom.getByLabel('显示名称').fill('Acme Gateway')
await custom.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
// No reasoning effort on a provider card at all: effort is a per-model
// capability, the models under one provider disagree about it, and a
// switch in the composer already records provider+model+effort together.
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
await dialog.getByRole('button', { name: '添加模型' }).click()
await dialog.getByLabel('模型 ID 1').fill('acme-large')
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
await custom.getByLabel('模型 ID 1').fill('acme-large')
await custom.getByRole('button', { name: '模型选项 1' }).click()
expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
await custom.getByRole('button', { name: '创建提供商', exact: true }).click()
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
@@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
for (let index = 0; index < 2; index++) {
await settings.getByRole('button', { name: /删除模型/ }).first().click()
}
await settings.getByRole('button', { name: '添加模型' }).click()
await settings.getByRole('button', { name: '添加模型', exact: true }).click()
const customModelId = settings.getByLabel('模型 ID 1')
await customModelId.fill('private-preview')
await settings.getByLabel('显示名称 1').fill('Private Preview')
@@ -58,10 +58,10 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
await setupKey.waitFor({ timeout: 10_000 })
const add = settings.getByRole('button', { name: '添加提供方' })
const add = settings.getByRole('button', { name: '添加模型提供商' })
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
await add.click()
const pick = settings.getByLabel('提供方')
const pick = settings.getByLabel('提供商', { exact: true })
await pick.waitFor({ timeout: 10_000 })
await pick.selectOption('minimax-cn')
await expect.poll(
@@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
// Cancelling the setup card must not close the independent add-provider
// draft beside it.
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
expect(await settings.getByLabel('提供方').count()).toBe(1)
expect(await settings.getByLabel('提供商', { exact: true }).count()).toBe(1)
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 76 KiB

+2 -2
View File
@@ -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 docs/user/guide/providers.md
providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1
providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495
providers.md: ef016ef39d5e34f3ce3dd2feb6213b9597668592
providers.zh.md: 15fd229ce222a020e897f08c15afcd843791a3bb
+6 -6
View File
@@ -8,21 +8,21 @@ This guide assumes you started the Web UI through the [root README](../../../REA
Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it.
![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png)
![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png)
Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference.
## Add a built-in provider
## Add a third-party provider
Choose **Add provider** and pick a provider dsh ships with; the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM. Enter its API key and save. The installed catalog supplies the endpoint, protocol, and model list.
Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
Providers that sign in with OAuth, such as Codex, are not supported here yet.
## Add a custom provider
## Add a custom model API
Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the form offers three: `openai-completions` for OpenAI Chat Completions, `openai-responses` for the OpenAI Responses API, and `anthropic-messages` for the Anthropic Messages API. A provider speaks one protocol, so a gateway that serves two needs two providers.
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable.
+23 -23
View File
@@ -8,39 +8,39 @@
打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。
![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png)
![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png)
密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。
## 添加内置提供方
## 添加第三方提供商
选择**添加提供方**,选取 dsh 自带的提供方;列表显示的是提供方 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`。输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
通过 OAuth 登录的提供方(例如 Codex)暂不支持。
通过 OAuth 登录的提供商(例如 Codex)暂不支持。
## 添加自定义提供方
## 添加自定义模型 API
对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,表单提供三种:`openai-completions` 对应 OpenAI Chat Completions,`openai-responses` 对应 OpenAI Responses API,`anthropic-messages` 对应 Anthropic Messages API。一个提供方只使用一种协议,网关同时提供两种时需要建两个提供方。
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。
Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供商,请添加新提供商并删除旧提供商。显示名称、基础 URL、协议、凭据和模型仍可编辑。
### 探测模型
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供方则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供方之前不会存储任何内容。
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供方一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供方探测。
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。
## 选择模型
已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
已配置的提供商会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。
如果已保存默认值指向已删除的提供商,输入框会显示**选择模型**,并在选择其他模型前阻止输入。
## 进阶配置
自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供商段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
::: tip 其他设置
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
@@ -48,9 +48,9 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
### 图片输入
在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。
在**设置 → 模型**中编辑提供商,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。
复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型:
复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供商声明了一个纯文本模型和一个视觉模型:
```yaml
llm-pi-ai:
@@ -86,7 +86,7 @@ llm-pi-ai:
- id: second-model
```
`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供商上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供商没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
```yaml
llm-pi-ai:
@@ -99,11 +99,11 @@ llm-pi-ai:
在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。
这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。
### 推理等级
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供方的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级:
对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级:
```yaml
llm-pi-ai:
@@ -136,7 +136,7 @@ llm-pi-ai:
max: max
```
网关并不提供推理能力的内置提供方模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
```yaml
llm-deepseek:
@@ -181,8 +181,8 @@ llm-pi-ai:
## 排错
- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。
- **`MISSING_CREDENTIAL`**:通过模型页存储提供商密钥,或提供被引用的环境变量。
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供商添加缺失的模型。
- **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。
- **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。
- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。
@@ -190,5 +190,5 @@ llm-pi-ai:
- **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。
- **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。
- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
- **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供商的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。
- **提供商拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。
@@ -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: c1567814b5de39f9b8819fcc8848e36c0a6ff842
README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27
README.md: 619941e125d6c8607246b5924c66c9a11af47948
README.zh.md: c4ba114dbb6501f564f810efc339114ea902604e
+3 -2
View File
@@ -43,7 +43,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid
### Adding and deleting providers
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. 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, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open, so switching modes discards neither draft. 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.
### First-run dialogs
@@ -110,7 +110,8 @@ These limits define the editor's field coverage and the page's reach; they are c
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved.
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first.
- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
@@ -43,7 +43,7 @@ kind: "package-reference"
### 新增与删除提供方
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点占位示例随所选协议变化: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 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用。面板在其方式首次显示时挂载,之后在卡片打开期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿。自定义表单会索要唯一的 **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 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
### 首次运行弹窗
@@ -110,7 +110,8 @@ kind: "package-reference"
- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。
- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
@@ -3,12 +3,13 @@
* gateway, a self-hosted server, or a provider newer than the installed
* catalog.
*
* This is a create, not an edit, which is why it is its own card rather than
* This is a create, not an edit, which is why it is its own form rather than
* the provider editor with extra fields: the route id is being *chosen* here,
* and the settings address does not exist until it is. One `settings.mutate`
* sets the whole profile at `providers.<route>`; the key travels separately
* through `credentials/set` under the reference the profile records, exactly as
* an existing provider's key does.
* and the settings address does not exist until it is. It renders as the
* custom-API panel of the section's add card, whose mode switch is its title.
* One `settings.mutate` sets the whole profile at `providers.<route>`; the key
* travels separately through `credentials/set` under the reference the profile
* records, exactly as an existing provider's key does.
*
* The three fields a hand-declared route cannot default — endpoint, protocol,
* and at least one model — are required here rather than at load, so the
@@ -30,6 +31,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import type { ModelDraft } from './ModelListEditor.tsx'
import { deriveKeyRef } from './store.ts'
import { protocolLabel } from './protocol-label.ts'
import type { ModelsOperations } from './operations.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -198,9 +200,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
return (
<div className={styles['editor']}>
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{t('customTitle')}</span>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
<input
@@ -255,7 +254,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
disabled={profileDisabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
{protocols.map(choice => <option key={choice} value={choice}>{protocolLabel(t, choice)}</option>)}
</select>
</div>
<div className={styles['field']}>
@@ -305,20 +305,16 @@
gap: 12px;
}
/* The two ways to gain a provider, as equal siblings spanning the same width
as the rows above. Wraps rather than shrinking below a legible label. */
/* The one entry to the add card, spanning the same width as the rows above. */
.addActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.addButton {
/* Overrides the shared button base above: these two are not pills sitting in
a footer but the last slot of the provider list, so they split the row
evenly and repeat the row cards' corner. Dashed, like every other "nothing
here yet" affordance on this page, to read as a place rather than a
command. */
/* Overrides the shared button base above: this is not a pill sitting in a
footer but the last slot of the provider list, so it fills the row and
repeats the row cards' corner. Dashed, like every other "nothing here yet"
affordance on this page, to read as a place rather than a command. */
flex: 1 1 0;
min-width: 180px;
gap: 6px;
@@ -327,6 +323,28 @@
border-radius: 16px;
}
/* The add card's mode switch with the one-line purpose of the chosen mode
beneath it; the switch hugs its labels rather than spanning the card. */
.addModes {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
/* One panel per mode. Both stay mounted while the card is open; the hidden
attribute must win over the panel's own display, or the inactive draft
would show beneath the active one. */
.addPanel {
display: flex;
flex-direction: column;
gap: 14px;
}
.addPanel[hidden] {
display: none;
}
.addCard,
.setupCard {
border-radius: 12px;
@@ -5,16 +5,21 @@
* solid configured or missing dots. A whole-section provider without a
* configured key renders as its open setup card instead of a row, but only in
* the first-run posture — no provider on the page can serve requests yet — and
* only until the user closes that card; the add flow is a card carrying the
* dormant-provider select. Each card kind owns its own open state, so closing
* one never discards a draft in another. Every mutation writes through the
* wire, while a provider removal first requires confirmation; the page
* re-renders from pushed invalidations or the post-apply reload.
* only until the user closes that card. The add flow is one card behind one
* button: a mode switch chooses between adopting a dormant directory provider
* (the catalog select over the provider editor) and declaring a custom model
* API (the create form). A panel mounts the first time its mode is shown and
* stays mounted, hidden, while the card is open, so switching modes discards
* neither draft and an unvisited mode costs nothing. Each card kind owns its
* own open state, so closing one never discards a draft in another. Every
* mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import { Button, IconPlusOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconPlusOutlineRegular, Modal, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls this package's SlotMap merge (the two Models child slots).
import type {} from './slot-contract.ts'
@@ -43,6 +48,12 @@ export interface ModelsSectionInjected {
t: (key: keyof typeof en) => string
}
/**
* The two ways the add card gains a provider: adopt a directory row the
* adapter already knows, or declare a route it does not.
*/
type AddMode = 'catalog' | 'custom'
/** The child slots this section declares and dispatches (see ./slot-contract.ts). */
type ModelsChildSlots = 'settings.models.provider-card' | 'settings.models.footer'
@@ -205,12 +216,14 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const { controller, operations, schema, t } = injected
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [addMode, setAddMode] = useState<AddMode>('catalog')
/** The modes shown since the add card opened; each keeps its panel mounted. */
const [visited, setVisited] = useState<ReadonlySet<AddMode>>(() => new Set())
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
const [declaring, setDeclaring] = useState(false)
const [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
const announceSaved = (target: ProviderIdentity): void => {
@@ -222,17 +235,16 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
setAddOpen(false)
if (changed) announceSaved(target)
}
/**
* Close a setup card, which owns none of the state above: the row-editor,
* add, and declare cards each own one of those, so clearing them here would
* discard a draft the user opened beside this card. Dismissal is this card's
* own — the provider falls back to an ordinary row for the rest of the
* session, and reopens through Edit.
* Close a setup card, which owns none of the state above: the row-editor
* and add cards each own one of those, so clearing them here would discard
* a draft the user opened beside this card. Dismissal is this card's own —
* the provider falls back to an ordinary row for the rest of the session,
* and reopens through Edit.
*/
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
setDismissedSetup(previous => new Set([...previous, target.provider]))
@@ -292,7 +304,19 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const configured = state.rows.filter(row => row.configured)
const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs))
const addable = configurable.filter(row => !row.configured)
const addTarget = adding ? editing : undefined
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the mode is not offered.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
// Each mode is offered while its namespace is mounted and enabled while it
// has something to offer; the card shows the chosen mode where both are
// offered, else the only one there is.
const catalogOffered = configurable.length > 0
const catalogEnabled = addable.length > 0
const customOffered = state.namespaces.has('llm-pi-ai')
const customEnabled = protocols.length > 0
const mode: AddMode = catalogOffered && customOffered ? addMode : customOffered ? 'custom' : 'catalog'
const addTarget = addOpen && catalogOffered ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
// The draft's directory row, for the card extension seat. A refresh can drop
// the row mid-draft (the route was adopted or withdrawn elsewhere); the
@@ -300,10 +324,6 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const addRow = addTarget === undefined
? undefined
: state.rows.find(row => row.entry.provider === addTarget.provider)
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
return (
<div className={styles['section']}>
@@ -349,7 +369,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
</li>
)
}
const open = !adding && editing?.provider === row.entry.provider
const open = !addOpen && editing?.provider === row.entry.provider
const credentialConfigured = row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
@@ -392,11 +412,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
aria-label={providerCopy(t('editProvider'), target)}
onClick={() => {
setSavedTarget(undefined)
// One card at a time: leaving `declaring` set would show
// the create card beside this editor, and closing either
// one discards the other's draft.
setDeclaring(false)
setAdding(false)
// One card at a time: the add card closes with whatever
// it held, since closing either card would otherwise
// discard the other's draft.
setAddOpen(false)
setEditing(open ? undefined : target)
}}
>
@@ -443,110 +462,130 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
})}
</ul>
<div className={styles['addBlock']}>
{addTarget !== undefined && addNamespace !== undefined
{addOpen
? (
<div className={styles['addCard']}>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('provider')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={addTarget.provider}
aria-label={t('provider')}
onChange={(event) => {
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
/* v8 ignore next -- the select only lists addable rows */
if (row === undefined) return
setEditing(targetOf(row))
}}
>
{addable.map(row => (
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
))}
</select>
</div>
<ProviderEditor
key={addTarget.provider}
provider={addTarget.provider}
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
schema={schema}
settingsPath={addTarget.settingsPath}
operations={operations}
t={t}
readOnly={!state.writable}
onClose={(changed) => { closeEditor(changed, addTarget) }}
/>
{addRow === undefined
? null
: renderSlot(
'settings.models.provider-card',
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
{ entryKey: addRow.entry.settingsNs },
)}
{catalogOffered && customOffered
? (
<div className={styles['addModes']}>
<SegmentedControl
label={t('addMode')}
value={mode}
options={[
{ value: 'catalog', label: t('addCatalog'), disabled: !catalogEnabled },
{ value: 'custom', label: t('addCustom'), disabled: !customEnabled },
]}
onChange={(next) => {
setAddMode(next)
setVisited(previous => new Set([...previous, next]))
}}
/>
<p className={styles['advancedHint']}>
{t(mode === 'catalog' ? 'addCatalogHint' : 'addCustomHint')}
</p>
</div>
)
: null}
{visited.has('catalog') && addTarget !== undefined && addNamespace !== undefined
? (
<div
role="tabpanel"
aria-label={t('addCatalog')}
hidden={mode !== 'catalog'}
className={styles['addPanel']}
>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('provider')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={addTarget.provider}
aria-label={t('provider')}
onChange={(event) => {
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
/* v8 ignore next -- the select only lists addable rows */
if (row === undefined) return
setEditing(targetOf(row))
}}
>
{addable.map(row => (
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
))}
</select>
</div>
<ProviderEditor
key={addTarget.provider}
provider={addTarget.provider}
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
schema={schema}
settingsPath={addTarget.settingsPath}
operations={operations}
t={t}
readOnly={!state.writable}
onClose={(changed) => { closeEditor(changed, addTarget) }}
/>
{addRow === undefined
? null
: renderSlot(
'settings.models.provider-card',
{ provider: addRow.entry, configured: addRow.configured, keyConfigured: keyConfiguredOf(addRow) },
{ entryKey: addRow.entry.settingsNs },
)}
</div>
)
: null}
{visited.has('custom')
? (
<div
role="tabpanel"
aria-label={t('addCustom')}
hidden={mode !== 'custom'}
className={styles['addPanel']}
>
<CustomProviderCard
taken={state.rows.map(row => row.entry.provider)}
protocols={protocols}
/* v8 ignore next -- the panel only renders with this namespace mounted */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
operations={operations}
t={t}
readOnly={!state.writable}
onClose={(changed) => {
setAddOpen(false)
if (changed) void controller.load()
}}
/>
</div>
)
: null}
</div>
)
: declaring
: catalogOffered || customOffered
? (
<div className={styles['addCard']}>
<CustomProviderCard
taken={state.rows.map(row => row.entry.provider)}
protocols={protocols}
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
operations={operations}
t={t}
readOnly={!state.writable}
onClose={(changed) => {
setDeclaring(false)
if (changed) void controller.load()
// One entry for both ways to gain a provider; the card behind it
// splits them. Full width, so it lines up with the rows above.
<div className={styles['addActions']}>
<button
type="button"
className={styles['addButton']}
disabled={!state.writable || (!catalogEnabled && !customEnabled)}
onClick={() => {
const first = addable[0]
const initial: AddMode = catalogEnabled ? 'catalog' : 'custom'
setSavedTarget(undefined)
setEditing(first === undefined ? undefined : targetOf(first))
setAddMode(initial)
setVisited(new Set([initial]))
setAddOpen(true)
}}
/>
>
<IconPlusOutlineRegular size={14} />
{t('add')}
</button>
</div>
)
: (
// One row for the two ways to gain a provider: adopt one the
// adapter already knows, or declare one it does not. Side by side
// and equal-width so they read as siblings and line up with the
// rows above, rather than two pills of different lengths.
<div className={styles['addActions']}>
{configurable.length > 0 && (
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setSavedTarget(undefined)
setDeclaring(false)
setAdding(true)
setEditing(targetOf(first))
}}
>
<IconPlusOutlineRegular size={14} />
{t('add')}
</button>
)}
{state.namespaces.has('llm-pi-ai') && (
<button
type="button"
className={styles['addButton']}
disabled={protocols.length === 0 || !state.writable}
onClick={() => {
setSavedTarget(undefined)
setAdding(false)
setEditing(undefined)
setDeclaring(true)
}}
>
<IconPlusOutlineRegular size={14} />
{t('customAdd')}
</button>
)}
</div>
)}
: null}
</div>
{renderSlot('settings.models.footer', {})}
<Modal
@@ -34,6 +34,7 @@ import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, protocolChoices } from './store.ts'
import { protocolLabel } from './protocol-label.ts'
import type { ModelsOperations } from './operations.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { en } from './locales.ts'
@@ -445,7 +446,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
reader announces it either way, and an empty one is
announced as a choice with no identity. */}
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
{protocols.map(choice => <option key={choice} value={choice}>{protocolLabel(t, choice)}</option>)}
</select>
</div>
)
@@ -14,7 +14,12 @@ export const en = {
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
deleteConfirm: 'Delete {provider}',
deleting: 'Deleting {provider}…',
add: 'Add provider',
add: 'Add model provider',
addMode: 'How to add',
addCatalog: 'Third-party model provider',
addCustom: 'Custom model API',
addCatalogHint: 'Pick OpenAI, Anthropic, Kimi, or another provider from the built-in catalog and enter its API key.',
addCustomHint: 'Connect a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint by its base URL, protocol, and models.',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
@@ -79,8 +84,6 @@ export const en = {
fetchSelectAll: 'Select all',
fetchDeselectAll: 'Deselect all',
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
customTag: 'Custom',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
@@ -89,6 +92,9 @@ export const en = {
customDisplayName: 'Display name',
customApi: 'API protocol',
customApiUnset: 'Not selected',
protocolOpenAiCompletions: 'OpenAI Chat Completions',
protocolOpenAiResponses: 'OpenAI Responses',
protocolAnthropicMessages: 'Anthropic Messages',
customNeedsBaseUrl: 'A custom provider needs a base URL.',
customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.',
customNeedsModels: 'A custom provider needs at least one model.',
@@ -116,7 +122,7 @@ export type ModelsKey = keyof typeof en
export const zh: { [Key in keyof typeof en]: string } = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
intro: '填入各提供商的 API 密钥即可使用其模型。',
edit: '编辑',
editProvider: '编辑 {provider}',
remove: '删除',
@@ -126,8 +132,13 @@ export const zh: { [Key in keyof typeof en]: string } = {
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
deleteConfirm: '删除 {provider}',
deleting: '正在删除 {provider}…',
add: '添加提供方',
provider: '提供方',
add: '添加模型提供商',
addMode: '添加方式',
addCatalog: '第三方模型提供商',
addCustom: '自定义模型 API',
addCatalogHint: '从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。',
addCustomHint: '连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。',
provider: '提供商',
close: '关闭',
cancel: '取消',
apply: '保存',
@@ -136,7 +147,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
credentialConfigured: 'API 密钥已配置',
credentialMissing: 'API 密钥缺失',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
loadFailed: '加载提供商目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
retry: '重试',
keyInput: 'API 密钥',
@@ -146,7 +157,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
keyEnvLocked: '由启动环境提供(只读)',
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
baseUrlDefault: '提供商默认',
deepSeekChatBaseUrl: 'https://api.deepseek.com',
deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic',
deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。',
@@ -159,9 +170,9 @@ export const zh: { [Key in keyof typeof en]: string } = {
modelName: '显示名称',
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
contextWindowPlaceholder: '使用提供商默认值',
maxTokens: '最大输出 token 数',
maxTokensPlaceholder: '使用提供方默认值',
maxTokensPlaceholder: '使用提供商默认值',
modelAdvanced: '模型选项',
modelInputTypes: '输入类型',
modelInputText: '文本',
@@ -170,7 +181,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
keyBlankNew: '请输入 API 密钥;若该提供商以其他方式鉴权,可以留空。',
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
@@ -181,33 +192,34 @@ export const zh: { [Key in keyof typeof en]: string } = {
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
modelDuplicate: '每个模型 ID 只能出现一次。',
fetchModels: '获取可用模型',
fetching: '正在询问提供方\u2026',
fetching: '正在询问提供商\u2026',
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
fetchEmpty: '该提供商没有列出任何模型,请手动添加。',
fetchTitle: '选择要添加的模型',
fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。',
fetchDescription: '以下是模型提供商的可用模型,勾选要添加的模型。',
fetchSearch: '搜索模型',
fetchNoMatches: '没有匹配的模型。',
fetchSelectAll: '全选',
fetchDeselectAll: '取消全选',
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customTag: '自定义',
customRoute: 'Provider ID',
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供商,并用于派生凭据名。',
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customRouteTaken: '已有提供商使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customApiUnset: '未选择',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
protocolOpenAiCompletions: 'OpenAI Chat Completions',
protocolOpenAiResponses: 'OpenAI Responses',
protocolAnthropicMessages: 'Anthropic Messages',
customNeedsBaseUrl: '自定义模型 API 需要填写 API 地址。',
customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
customNeedsModels: '自定义模型 API 至少需要一个模型。',
customBaseUrlPlaceholder: 'https://gateway.example/v1',
customAnthropicBaseUrlPlaceholder: 'https://gateway.example',
settingsPathUnresolvable: '无法解析设置路径',
create: '创建提供方',
create: '创建提供商',
creating: '创建中\u2026',
welcomeTitle: '内测声明',
welcomeBody: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
@@ -0,0 +1,27 @@
/**
* Product names for the wire protocols a pi-ai route may speak. The pickers
* show these instead of the schema identifiers (`openai-completions`), which
* are what `settings.yaml` carries and what the option values stay.
*/
import type { ModelsKey } from './locales.ts'
/** The protocols this page names, keyed by their schema identifier. */
const PROTOCOL_LABEL_KEYS: Readonly<Record<string, ModelsKey>> = {
'openai-completions': 'protocolOpenAiCompletions',
'openai-responses': 'protocolOpenAiResponses',
'anthropic-messages': 'protocolAnthropicMessages',
}
/**
* The picker text for one protocol identifier.
* @param t - section copy.
* @param protocol - the schema identifier of the protocol.
* @returns the product name this page knows the protocol by; a protocol the
* adapter adds before this page names it shows its identifier, the spelling
* `settings.yaml` needs anyway.
*/
export function protocolLabel(t: (key: ModelsKey) => string, protocol: string): string {
const key = PROTOCOL_LABEL_KEYS[protocol]
return key === undefined ? protocol : t(key)
}
@@ -46,6 +46,7 @@ function capacityInputs(label: string): HTMLInputElement[] {
const PiAiConfig = Schema.object({
providers: Schema.dict(Schema.object({
apiKeyEnv: Schema.string().role('credential-ref'),
api: Schema.union(['openai-completions', 'openai-responses', 'anthropic-messages']),
baseURL: Schema.string(),
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
headers: Schema.dict(Schema.string()),
@@ -307,12 +308,11 @@ async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] =
}
describe('ModelsSection', () => {
it('hides both add actions when their settings namespaces are absent', async () => {
it('hides the add action when no settings namespace can open an editor', async () => {
const scripted = scriptedFace()
scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] }))
await mountFace(scripted)
expect(screen.queryByRole('button', { name: en.add })).toBeNull()
expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
})
it('offers only providers whose settings namespace can open an editor', async () => {
@@ -322,8 +322,11 @@ describe('ModelsSection', () => {
namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'),
}))
await mountFace(scripted)
expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.add }))
// Without the pi-ai namespace nothing can be hand-declared, so the card
// is the catalog form alone: no mode switch, no custom panel.
expect(screen.queryByRole('tablist')).toBeNull()
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull()
expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy()
})
@@ -339,7 +342,6 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
expect(await screen.findByLabelText(en.keyInput)).toBeTruthy()
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
})
it('renders nothing before the slot injects its dependencies', () => {
@@ -1437,6 +1439,88 @@ describe('ModelsSection', () => {
expect(mutate).not.toHaveBeenCalled()
})
it('opens the add card on the catalog mode with both modes offered', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.add }))
const modes = screen.getByRole('tablist', { name: en.addMode })
expect(within(modes).getByRole('tab', { name: en.addCatalog }).getAttribute('aria-selected')).toBe('true')
expect(within(modes).getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('false')
expect(screen.getByText(en.addCatalogHint)).toBeTruthy()
expect(screen.queryByText(en.addCustomHint)).toBeNull()
expect(screen.getByRole('combobox', { name: en.provider })).toBeTruthy()
// The custom panel is mounted but hidden, so its fields are not reachable.
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
})
it('switches between the modes without losing either draft', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.add }))
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-catalog' } })
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
expect(screen.getByText(en.addCustomHint)).toBeTruthy()
expect(screen.queryByText(en.addCatalogHint)).toBeNull()
expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull()
fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } })
// Both panels stay mounted from here on, so the shown one is queried by name.
fireEvent.click(screen.getByRole('tab', { name: en.addCatalog }))
const catalog = screen.getByRole('tabpanel', { name: en.addCatalog })
expect(within(catalog).getByLabelText<HTMLInputElement>(en.keyInput).value).toBe('sk-catalog')
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
expect(screen.getByRole<HTMLInputElement>('textbox', { name: en.customRoute }).value).toBe('acme')
})
it('opens on the custom mode when every catalog provider is already configured', async () => {
const scripted = scriptedFace()
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
]))
await mountFace(scripted)
fireEvent.click(screen.getByRole('button', { name: en.add }))
const catalog = screen.getByRole<HTMLButtonElement>('tab', { name: en.addCatalog })
expect(catalog.disabled).toBe(true)
expect(screen.getByRole('tab', { name: en.addCustom }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
})
it('shows the custom form alone when no directory row can be adopted', async () => {
const scripted = scriptedFace()
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([]))
await mountFace(scripted)
fireEvent.click(screen.getByRole('button', { name: en.add }))
expect(screen.queryByRole('tablist')).toBeNull()
expect(screen.queryByRole('combobox', { name: en.provider })).toBeNull()
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
})
it('disables the add action when neither mode can proceed', async () => {
const scripted = scriptedFace()
scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
]))
// A pi-ai schema naming no protocol leaves nothing to declare either.
const protocolless = JSON.parse(JSON.stringify(
Schema.object({ providers: Schema.dict(Schema.object({})) }).toJSON(),
)) as JsonValue
scripted.face.settings.describe.mockResolvedValue(remoteOk({
writable: true, hasDocument: false,
namespaces: wireNamespaces().map(view => view.ns === 'llm-pi-ai' ? { ...view, schema: protocolless } : view),
}))
await mountFace(scripted)
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.add }).disabled).toBe(true)
})
it('collapses the add card from the custom mode on cancel', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.add }))
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
fireEvent.click(within(screen.getByRole('tabpanel', { name: en.addCustom })).getByText(en.cancel))
expect(screen.queryByRole('tablist')).toBeNull()
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
})
it('cancels the add card back to the add button', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.add))
@@ -225,6 +225,12 @@ function openEditor(provider: string): void {
fireEvent.click(summary)
}
/** Open the add card from the section and switch it to the custom-API mode. */
function openCustomMode(): void {
fireEvent.click(screen.getByRole('button', { name: en.add }))
fireEvent.click(screen.getByRole('tab', { name: en.addCustom }))
}
/** Open one model row's advanced fold, where the capacities live. */
function expandModel(index: number): void {
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
@@ -1417,27 +1423,40 @@ describe('hand-declared providers', () => {
expect(buttonNamed(en.create).disabled).toBe(true)
})
it('closes the create card when an existing row is opened for editing', async () => {
it('closes the add card when an existing row is opened for editing', async () => {
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
openCustomMode()
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
// Two cards at once would each be closable by the other: whichever one is
// dismissed clears the shared state and discards the other's draft.
openEditor('openai')
expect(screen.queryByText(en.customTitle)).toBeNull()
expect(screen.queryByRole('tablist')).toBeNull()
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
})
it('reaches the card from the section and returns to the button on cancel', async () => {
it('reaches the custom form from the section and returns to the button on cancel', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
openCustomMode()
expect(screen.getByRole('textbox', { name: en.customRoute })).toBeTruthy()
fireEvent.click(screen.getByText(en.cancel))
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
await waitFor(() => { expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull() })
expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
})
it('names each protocol by its product name and falls back to the identifier of an unknown one', () => {
mountCard({ protocols: [...PROTOCOLS, 'google-generative-ai'] })
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
const labels = [...protocol.options].map(option => [option.value, option.textContent])
expect(labels).toEqual([
['openai-completions', en.protocolOpenAiCompletions],
['openai-responses', en.protocolOpenAiResponses],
['anthropic-messages', en.protocolAnthropicMessages],
['google-generative-ai', 'google-generative-ai'],
])
})
it('refuses an unusable key on the field and blocks creation', () => {
@@ -1608,15 +1627,15 @@ describe('API key field', () => {
const { controller, mutate } = await mountSection()
const load = vi.spyOn(controller, 'load')
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
openCustomMode()
fireEvent.change(screen.getByRole('textbox', { name: en.customRoute }), { target: { value: 'acme' } })
fireEvent.change(screen.getByRole('textbox', { name: en.baseUrl }), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
expect(screen.queryByText(en.customTitle)).toBeNull()
expect(screen.queryByRole('textbox', { name: en.customRoute })).toBeNull()
})
})