diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..729f2b010b 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 69e631f4cd..84f93fc533 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index e30e6cc2dd..d1047bfbb2 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index f294ff5977..0380871342 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index fcaeb76e56..6bc07d9474 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index d752bbb42a..269d5ebd35 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 8ce2f31098..4ddb04a13b 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -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 "保存" diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index e02c9b2529..01fd8e7166 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ - dialog "选择要添加的模型": - heading "选择要添加的模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 + - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。 - searchbox "搜索模型" - button "取消全选" - list: diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index be77e20835..c94af7233d 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 9e344cc148..ef3f761e7a 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -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 "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 0279600e91..f955841948 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -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 "保存" diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts index 1087f8be3d..3e2d664b93 100644 --- a/apps/web/tests/models-settings-recovery.e2e.ts +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -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: {}') diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 888b5d5d54..6d514cf97b 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -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 }) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 96ddb5e24c..d8e201a49d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -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') diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index d16ed51822..efc04dcdaa 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -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 }, diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png index bbbedde794..82ca074212 100644 Binary files a/docs/user/guide/providers-custom-form.png and b/docs/user/guide/providers-custom-form.png differ diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png index 2c48120331..9a2b7661ca 100644 Binary files a/docs/user/guide/providers-custom-form.zh.png and b/docs/user/guide/providers-custom-form.zh.png differ diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png index f3ffe6e90f..fb87367d54 100644 Binary files a/docs/user/guide/providers-models-page.png and b/docs/user/guide/providers-models-page.png differ diff --git a/docs/user/guide/providers-models-page.zh.png b/docs/user/guide/providers-models-page.zh.png index b5d1f4597c..694f1b6f16 100644 Binary files a/docs/user/guide/providers-models-page.zh.png and b/docs/user/guide/providers-models-page.zh.png differ diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 3097c5ae9e..f0043c374d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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 docs/user/guide/providers.md -providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1 -providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495 +providers.md: ef016ef39d5e34f3ce3dd2feb6213b9597668592 +providers.zh.md: 15fd229ce222a020e897f08c15afcd843791a3bb diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index e578ff96a3..ef016ef39d 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -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. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index fd0859f0b2..15fd229ce2 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -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`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c3c33c6f90..c81192e0e4 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: c1567814b5de39f9b8819fcc8848e36c0a6ff842 -README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 +README.md: 619941e125d6c8607246b5924c66c9a11af47948 +README.zh.md: c4ba114dbb6501f564f810efc339114ea902604e diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1567814b5..619941e125 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -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 `_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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index baa0f02e82..c4ba114dbb 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -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` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_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 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 4f382e92a1..92c1e762dd 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -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.`; 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.`; 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 (
-
- {t('customTitle')} -
{t('customRoute')} { setProtocol(event.target.value) }} > - {protocols.map(choice => )} + {protocols.map(choice => )}
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 15b8933fa3..d39e807876 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -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; diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index a9ce163a50..ce39f9f8cc 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -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(undefined) - const [adding, setAdding] = useState(false) + const [addOpen, setAddOpen] = useState(false) + const [addMode, setAddMode] = useState('catalog') + /** The modes shown since the add card opened; each keeps its panel mounted. */ + const [visited, setVisited] = useState>(() => new Set()) const [deleteTarget, setDeleteTarget] = useState(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) - const [declaring, setDeclaring] = useState(false) const [dismissedSetup, setDismissedSetup] = useState>(() => 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 (
@@ -349,7 +369,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS ) } - 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 })}
- {addTarget !== undefined && addNamespace !== undefined + {addOpen ? (
-
- {t('provider')} - -
- { 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 + ? ( +
+ { + setAddMode(next) + setVisited(previous => new Set([...previous, next])) + }} + /> +

+ {t(mode === 'catalog' ? 'addCatalogHint' : 'addCustomHint')} +

+
+ ) + : null} + {visited.has('catalog') && addTarget !== undefined && addNamespace !== undefined + ? ( + + ) + : null} + {visited.has('custom') + ? ( + + ) + : null}
) - : declaring + : catalogOffered || customOffered ? ( -
- 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. +
+
) - : ( - // 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. -
- {configurable.length > 0 && ( - - )} - {state.namespaces.has('llm-pi-ai') && ( - - )} -
- )} + : null}
{renderSlot('settings.models.footer', {})} {t('customApiUnset')} : null} - {protocols.map(choice => )} + {protocols.map(choice => )}
) diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..9cd6d52321 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -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 插件生态。', diff --git a/packages/client/ui-settings-models/src/client/protocol-label.ts b/packages/client/ui-settings-models/src/client/protocol-label.ts new file mode 100644 index 0000000000..f65323f32c --- /dev/null +++ b/packages/client/ui-settings-models/src/client/protocol-label.ts @@ -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> = { + '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) +} diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..125209e81b 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -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[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(en.keyInput).value).toBe('sk-catalog') + fireEvent.click(screen.getByRole('tab', { name: en.addCustom })) + expect(screen.getByRole('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('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('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)) 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 9b6ff1f55a..36dc899778 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 @@ -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(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() }) })