mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #2207 from deepseek-harness/worktree/custom-provider-edit-parity-f8c160
fix(web): 自定义提供方的显示名称与 API 协议可编辑
This commit is contained in:
@@ -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 .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md
|
||||
2026-08-04-declaring-a-provider-from-the-models-page.md: 44441da5427dbcd3f0651c2c137a5132ff6d968b
|
||||
2026-08-04-declaring-a-provider-from-the-models-page.zh.md: 974c4fa5be0893e8fdf6a9142877a8b966708dab
|
||||
2026-08-04-declaring-a-provider-from-the-models-page.md: 3e7ff9c3bd58b73185c669a224a59cd017ecfb42
|
||||
2026-08-04-declaring-a-provider-from-the-models-page.zh.md: a4255f8e29b696e84ff3accf636714cdfc0855f1
|
||||
|
||||
@@ -22,22 +22,30 @@ Fetching asks about the endpoint **the form currently shows** — a base URL edi
|
||||
|
||||
The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones.
|
||||
|
||||
The editor reaches the two fields a route the directory reports as **declared** names for itself — its display name and that protocol. A create card asking for a field no editor can change leaves that field reachable only through `settings.yaml`, which is the posture this note set out to end. Both render in the fold beside the endpoint, the protocol from the same schema read. Clearing the name unsets it, and what the route falls back to is the layer beneath the one the field edits — a `cordis.yml` may pin a name for a route the catalog does not ship, so the placeholder reads the composition layer and names the route id only when nothing pins one. The protocol has no fallback to clear to. Because an apply can now rename the route, the saved notice names it as the refreshed directory reports it rather than as the target captured when the card opened. A catalog route gets neither: it defaults its name from its catalog entry, and each of its models carries its own protocol, so a route-level one could only override every one of them.
|
||||
|
||||
The **Provider ID** is the one create-card field that stays fixed, and not for want of a control. It is the `providers.<route>` dict key, so changing it is a move rather than an edit, and the editor is addressed by the `settingsPath` that move would invalidate. It is referenced from outside this namespace — `agent-default-model` stores a `provider` string, and every `request/header` in every session log already records one — so a rename would silently strip meaning from referents this page cannot see. And it is the stem of the derived credential reference: the page writes keys but can never read one back, so it cannot move `OLD_API_KEY` to `NEW_API_KEY`, leaving a rename to either orphan the stored key or point the profile at a reference under the previous name. Declaring the new route and deleting the old one does all three explicitly, and the page already offers both halves.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing.
|
||||
|
||||
**Add a wire field for the protocol list.** Explicit. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema.
|
||||
|
||||
**Let the Provider ID be edited, with the page performing the move.** The card would unset the old key and set the new profile in one `settings.mutate`, and the rest is a rename. But the credential cannot travel with it — the page holds a redacted descriptor, never a value — and the referents in other namespaces and in logged sessions have no rename path at all, so the honest version of this feature is the create-then-delete the page already has.
|
||||
|
||||
**Offer the protocol on every pi-ai route, with an inherit choice.** Symmetric with the base URL beside it, and repointing a catalog route at a gateway speaking another wire protocol is a real thing to want. But no consumer asks for it, one wrong pick silently repoints every model on the route, and the inherit choice would be the only way to write a declared route into a profile the adapter refuses. `settings.yaml` still expresses the repoint for a deployment that means it.
|
||||
|
||||
**Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one.
|
||||
|
||||
**Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list.
|
||||
A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list, plus a name and a protocol on a declared route.
|
||||
|
||||
What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not.
|
||||
`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. The stylesheet gate reads the package's own sources and fails any `<select>` that takes `.input` without `.selectInput`, because the OS arrow it would otherwise keep sits flush inside the 240px cap `select.input` imposes. The editor's own field inventory is asserted per route kind — a catalog route stops at the key and the endpoint, a declared one also carries the protocol — along with the protocol edit travelling as a single `api` path op, a rename travelling as a single `displayName` one, a cleared name unsetting rather than storing the empty string the adapter refuses, and a declared profile naming no protocol selecting nothing rather than the first choice. `apps/web/tests/models-settings.e2e.ts` reopens the declared route through the real wire, captures the card, and asserts the chosen protocol and the new name both reach `settings.yaml` and the row re-registers under the rename.
|
||||
|
||||
@@ -22,22 +22,30 @@ Status: implemented
|
||||
|
||||
协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。
|
||||
|
||||
对于目录报告为**已声明**的路由,编辑器够得着它为自己命名的那两个字段——显示名称与该协议。创建卡片索要一个编辑器改不了的字段,等于把该字段留在只有 `settings.yaml` 才能触及的位置,而那正是本记录要终结的姿态。两者都渲染在折叠区里、紧挨着端点,协议读的是同一份 schema。清空名称即取消设置,而路由退回的是该字段所编辑层之下的那一层——`cordis.yml` 可以为目录未提供的路由钉一个名称,因此占位符读组合层,只有没人钉名称时才报路由 id。协议没有可退回的兜底。既然一次保存现在可以改名,保存回执便按刷新后的目录来点名这条路由,而不是按卡片打开时捕获的 target。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。
|
||||
|
||||
**Provider ID** 是创建卡片上唯一保持固定的字段,原因不是没做控件。它是 `providers.<route>` 这个字典键,因此改它是一次搬移而非一次编辑,而编辑器正是由那次搬移会作废的 `settingsPath` 寻址的。它还被本 namespace 之外引用——`agent-default-model` 存着一个 `provider` 字符串,每条会话日志里的每个 `request/header` 也都已经记下了一个——因此重命名会悄悄抽空这个页面看不见的那些引用。它同时是派生凭据引用的词干:页面写得了密钥却永远读不回来,因此无法把 `OLD_API_KEY` 搬到 `NEW_API_KEY`,重命名要么让已存密钥成为孤儿,要么让 profile 指向一个仍带旧名的引用。声明新路由再删掉旧的,把这三件事都显式做了一遍,而页面本就提供这两半。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。
|
||||
|
||||
**为协议列表新增一个协议字段。** 显式。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。
|
||||
|
||||
**开放 Provider ID 编辑,由页面来完成这次搬移。** 卡片可以在一次 `settings.mutate` 里取消旧键、设置新 profile,剩下的就只是改名。但凭据没法跟着走——页面手里只有脱敏描述符,从来没有值——而其他 namespace 与已记录会话里的引用根本没有重命名通路,因此这个功能诚实的版本,就是页面已经具备的「先建后删」。
|
||||
|
||||
**给每条 pi-ai 路由都提供协议,并附一个「继承」选项。** 与紧挨着的 API 地址对称,而且把内置目录路由指向讲另一种协议的网关确实是有人会想要的事。但目前没有消费方提出这个诉求,一次选错就会静默地把该路由上每个模型都重指,而「继承」选项还会成为把已声明路由写成适配器拒绝的 profile 的唯一途径。真要这么做的部署,`settings.yaml` 仍然表达得了。
|
||||
|
||||
**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。
|
||||
|
||||
**把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。
|
||||
|
||||
## Consequences
|
||||
|
||||
网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。
|
||||
网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表,已声明路由上还多了一个名称输入框和一个协议选择框。
|
||||
|
||||
代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。
|
||||
`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。样式 gate 读取本包自己的源码,任何只取 `.input` 而不取 `.selectInput` 的 `<select>` 都会失败——否则它保留的系统箭头会紧贴 `select.input` 所设 240px 上限的右边缘。编辑器自身的字段清单按路由种类各有断言——内置目录路由止于密钥与端点,已声明路由还带着协议——同时覆盖协议改动只以单条 `api` path op 传出、改名只以单条 `displayName` path op 传出、清空名称是取消设置而不是存入适配器会拒绝的空串,以及不写协议的已声明 profile 什么都不选中、而非选中第一个候选。`apps/web/tests/models-settings.e2e.ts` 经真实协议层重新打开这条已声明路由,捕获该卡片,并断言选定的协议与新名称都抵达了 `settings.yaml`、该行也以新名重新注册。
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference. Each saved row
|
||||
// appears after route topology invalidation without presenting liveness as
|
||||
// provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// provider status. The customized-settings fold writes its curated fields —
|
||||
// the endpoint, and a declared route's own name and protocol — as merge
|
||||
// patches against the stored profile. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
@@ -28,6 +29,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
||||
const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
|
||||
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -209,6 +211,40 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('reopens the name and protocol a declared route was created with', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
// The create card asked this route for a name and a protocol because
|
||||
// nothing can default them; the editor reaches the same two fields rather
|
||||
// than sending the user to settings.yaml for what only this route names.
|
||||
const protocol = dialog.getByLabel('API 协议')
|
||||
await protocol.waitFor({ timeout: 10_000 })
|
||||
expect(await protocol.inputValue()).toBe('openai-completions')
|
||||
const name = dialog.getByLabel('显示名称', { exact: true })
|
||||
expect(await name.inputValue()).toBe('Acme Gateway')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
|
||||
|
||||
await protocol.selectOption('anthropic-messages')
|
||||
await name.fill('Acme 网关')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
|
||||
// The adapter re-resolved the route under the new protocol and re-registered
|
||||
// it under the new name: an unserviceable profile would have been refused
|
||||
// at the write instead, and a rename that did not re-register would leave
|
||||
// the old label on the row.
|
||||
await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 })
|
||||
// The status line names the route as the refreshed directory reports it;
|
||||
// the target captured when the card opened still carries the old name.
|
||||
await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('api: anthropic-messages')
|
||||
expect(document).toContain('displayName: Acme 网关')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms an identified provider deletion before removing its profile and key', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -243,8 +279,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
|
||||
'empty.expected.md', 'native-delete.expected.md',
|
||||
'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
|
||||
'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- listitem:
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑 Acme Gateway (acme-gateway)": 编辑
|
||||
- button "删除 Acme Gateway (acme-gateway)": 删除
|
||||
- text: Acme Gateway acme-gateway API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group:
|
||||
- text: 自定义设置 显示名称
|
||||
- textbox "显示名称":
|
||||
- /placeholder: acme-gateway
|
||||
- text: Acme Gateway
|
||||
- text: API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://gateway.acme.example/v1
|
||||
- text: https://gateway.acme.example/v1
|
||||
- text: API 协议
|
||||
- combobox "API 协议":
|
||||
- option "openai-completions" [selected]
|
||||
- option "openai-responses"
|
||||
- option "anthropic-messages"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
- button "获取可用模型"
|
||||
- textbox "模型 ID 1":
|
||||
- /placeholder: 模型 ID
|
||||
- text: acme-large
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- button "容量 1"
|
||||
- button "删除模型 1"
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -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: 31b4be666bd6b7a402a0bae7bc8f810fd2d928d8
|
||||
providers.zh.md: bd95c63cacd9718b3ffc1f78e17270e7022b21e0
|
||||
providers.md: 0e7ed11d1b09a8361d75b576a400978ac66d08a7
|
||||
providers.zh.md: 060bf3dc41b773e89cd0d78de921c3a20cfc6076
|
||||
|
||||
@@ -29,6 +29,8 @@ That holds for providers that authenticate with an API key. The catalog also car
|
||||
|
||||

|
||||
|
||||
Every field but the Provider ID stays editable afterwards: **Edit** on the row reopens the same fields, with the display name and the protocol under **Customized settings** beside the base URL. Clearing the display name falls back to the Provider ID. The Provider ID itself is fixed: it names the route in requests, in `agent-default-model`, and in every session already logged, and it is the stem of the credential reference the page can never read back — so renaming a route means declaring a new provider and deleting the old one.
|
||||
|
||||
**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save.
|
||||
|
||||
Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.credentials.yaml`, and the profile records only the variable name that references it.
|
||||
|
||||
@@ -29,6 +29,8 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配
|
||||
|
||||

|
||||
|
||||
除 Provider ID 外的每个字段之后都还能改:行上的**编辑**会重新打开这些字段,显示名称和协议在「自定义设置」里、紧挨着 API 地址;显示名称清空即退回 Provider ID。Provider ID 本身固定不可改:它在请求里、在 `agent-default-model` 里、在每一条已记录的会话里点名这条路由,同时还是凭据引用的词干,而页面永远读不回凭据值——因此重命名一条路由等于声明一个新提供方再把旧的删掉。
|
||||
|
||||
**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。
|
||||
|
||||
密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.credentials.yaml`,profile 里只记录引用它的变量名。
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec
|
||||
README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b
|
||||
README.md: 89a253fca9f16ea5655cdd7536a441e98dcc4d3c
|
||||
README.zh.md: 350be495f1491738e6e861ba127b3b0070a5f073
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and 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. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
@@ -28,7 +28,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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 ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **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 ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). Both families expose `baseURL` and model `id`/`name`/`contextWindow`/`maxTokens`; a hand-declared pi-ai route also exposes `displayName` and `api`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **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.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
@@ -28,7 +28,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。两个家族都公开 `baseURL` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;手工声明的 pi-ai 路由还公开 `displayName` 与 `api`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
|
||||
@@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={profileDisabled}
|
||||
|
||||
@@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity {
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
/** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
@@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...target.declared === true ? { declared: true } : {}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
// Absent is not "shipped": an adapter that answers nothing leaves the
|
||||
// route-level fields only a declared route owns off the card, exactly as
|
||||
// it leaves the custom tag off the row.
|
||||
...row.entry.declared === true ? { declared: true } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +184,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +227,17 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
// The saved provider as the directory currently names it. The route id is
|
||||
// what the apply cannot change, so it is what the notice is keyed by; a row
|
||||
// the same apply removed keeps the captured identity, since nothing newer
|
||||
// exists to name it with.
|
||||
const savedRow = savedTarget === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === savedTarget.provider)
|
||||
const savedIdentity = savedRow === undefined
|
||||
? savedTarget
|
||||
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
|
||||
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
@@ -232,11 +252,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
{savedIdentity === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
{providerCopy(t('savedProvider'), savedIdentity)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families and DeepSeek's id/name/context-window model catalog).
|
||||
* both families, DeepSeek's id/name/context-window model catalog, and the
|
||||
* display name and wire protocol of a pi-ai route the adapter does not ship —
|
||||
* the two fields the create card asked that route for, editable here for the
|
||||
* same reason).
|
||||
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
|
||||
* the models under one provider disagree about it, so a provider-scoped
|
||||
* control can only be set to a value some of them reject. The composer's
|
||||
@@ -30,7 +33,7 @@ import {
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -48,6 +51,14 @@ export interface ProviderEditorProps {
|
||||
displayName: string
|
||||
/** Hide the title row (the add card renders its own provider select). */
|
||||
hideTitle?: boolean
|
||||
/**
|
||||
* Whether the adapter reports this route as hand-declared — absent from its
|
||||
* installed catalog. Such a route carries its own wire protocol, chosen when
|
||||
* it was created and editable here for the same reason; a catalog route's
|
||||
* models each carry theirs, so a route-level protocol there could only
|
||||
* override every one of them and the card does not offer it.
|
||||
*/
|
||||
declared?: boolean
|
||||
/** The owning namespace view (schema, layers, secrets). */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
@@ -139,6 +150,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const disabled = props.readOnly || busy
|
||||
const layout = layoutOf(namespace.ns)
|
||||
const keyRef = refFor(namespace, settingsPath, props.provider)
|
||||
// The same schema read the create card makes, so the choices offered here
|
||||
// and there cannot drift apart: both come from the adapter's own `Config`.
|
||||
// Only the pi-ai layout has a per-route protocol for the read to find, and
|
||||
// it rehydrates the whole section schema, so the other layouts skip it.
|
||||
const protocols = useMemo(
|
||||
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
|
||||
[layout, namespace],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -289,11 +308,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated fields of one known adapter family. Taking the narrowed
|
||||
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||
* unknown namespace never reaches this body.
|
||||
* The curated fields of one known adapter family. The family arrives
|
||||
* narrowed so the per-family branches below are total: an unknown namespace
|
||||
* renders the hint instead and never reaches this body.
|
||||
*/
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
// What a hand-declared route names for itself and nothing else can supply.
|
||||
// A whole-section `llm-deepseek` profile is a composition fact with no
|
||||
// per-route identity for its schema to carry, hence the family test.
|
||||
const ownsIdentity = family === 'pi-ai' && props.declared === true
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
@@ -334,6 +357,33 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{/* The name and the protocol are the create card's two remaining
|
||||
profile fields; a route the adapter ships defaults both from
|
||||
its catalog entry and neither belongs on its card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'displayName') ?? ''}
|
||||
// What this route is called the moment the field is
|
||||
// cleared, which is the layer beneath the one this field
|
||||
// edits: a `cordis.yml` may pin a name for a route the
|
||||
// catalog does not ship, and only when nothing does is
|
||||
// the answer the route id. Reading the effective value
|
||||
// instead would echo the stored override back as the
|
||||
// thing clearing restores.
|
||||
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
|
||||
?? props.provider}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('displayName', event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
@@ -350,6 +400,31 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The protocol sits beside the endpoint it describes, as it does
|
||||
on the create card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={probeApi ?? ''}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('api', event.target.value) }}
|
||||
>
|
||||
{/* A profile naming no protocol — hand-written into
|
||||
settings.yaml with no model to need one — selects
|
||||
nothing rather than reading as if it had picked the
|
||||
first choice. The option is named because a screen
|
||||
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>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const en = {
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
customApiUnset: 'Not selected',
|
||||
customNeedsBaseUrl: 'A custom provider needs a base URL.',
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
@@ -173,6 +174,7 @@ export const zh: typeof en = {
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
customApiUnset: '未选择',
|
||||
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
@@ -47,6 +47,7 @@ function fail<T>(message: string, code: string): RpcResponse<T> {
|
||||
function piAiNamespace(
|
||||
providers: Record<string, unknown>,
|
||||
userProviders: Record<string, unknown> = providers,
|
||||
baseProviders: Record<string, unknown> = {},
|
||||
): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -54,7 +55,7 @@ function piAiNamespace(
|
||||
// `value` is the effective section; `user` is only the layer this page
|
||||
// writes. They differ whenever a composition `base` supplies something.
|
||||
value: { providers },
|
||||
base: {},
|
||||
base: { providers: baseProviders },
|
||||
user: { providers: userProviders },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
@@ -66,6 +67,8 @@ function scriptedFace(options: {
|
||||
providers?: Record<string, unknown>
|
||||
/** User layer, when it differs from the effective section. */
|
||||
userProviders?: Record<string, unknown>
|
||||
/** Composition layer, for a route a `cordis.yml` pins rather than the page. */
|
||||
baseProviders?: Record<string, unknown>
|
||||
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
|
||||
declaredRoutes?: readonly string[]
|
||||
discover?: ReturnType<typeof vi.fn>
|
||||
@@ -75,7 +78,7 @@ function scriptedFace(options: {
|
||||
const providers = options.providers ?? {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
|
||||
}
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {})
|
||||
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
|
||||
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
@@ -719,10 +722,152 @@ describe('hand-declared providers', () => {
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// A shipped route's models each carry their own protocol, so its editor
|
||||
// offers no route-level protocol to override them with.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
cleanup()
|
||||
|
||||
// A hand-declared route named its own protocol at creation, so editing it
|
||||
// reaches the same field the create card asked for.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi])
|
||||
})
|
||||
|
||||
it('renames a declared route and falls back to its id when the name is cleared', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' },
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('Acme Gateway')
|
||||
// The route id, not the stored name: it is what the route will be called
|
||||
// the moment the field is cleared.
|
||||
expect(name.placeholder).toBe('acme-gateway')
|
||||
fireEvent.change(name, { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }])
|
||||
})
|
||||
|
||||
it('offers the composition name as what a cleared field falls back to', async () => {
|
||||
// A `cordis.yml` can pin a route the catalog does not ship, so a declared
|
||||
// route's profile is not always the page's own. The field edits the user
|
||||
// layer alone, and clearing it restores the layer beneath — the
|
||||
// composition name here, not the route id — so that is what it offers.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
baseProviders: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
userProviders: {},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('')
|
||||
expect(name.placeholder).toBe('Acme (pinned)')
|
||||
})
|
||||
|
||||
it('names the provider as the refreshed directory reports it after a rename', async () => {
|
||||
// The status line used to echo the target captured when the card opened,
|
||||
// which never lied while the name could not change. It can now.
|
||||
const { face } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
// The reload after the write answers with the renamed route, exactly as
|
||||
// the adapter re-registers it.
|
||||
face.llm.providers = vi.fn(() => Promise.resolve(ok({
|
||||
providers: [{
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'acme-gateway'],
|
||||
active: true,
|
||||
declared: true,
|
||||
}],
|
||||
})))
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
const notice = await screen.findByRole('status')
|
||||
expect(notice.textContent).toBe(providerCopy(en.savedProvider, {
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
}))
|
||||
})
|
||||
|
||||
it('drops the stored name rather than storing an empty one the adapter refuses', async () => {
|
||||
// `llm-pi-ai` rejects an empty displayName outright, so clearing the field
|
||||
// must unset it — which is also what the user means: use the route id.
|
||||
const { mutate } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }])
|
||||
})
|
||||
|
||||
it('edits the protocol a declared route was created with', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: 'acme-large' }],
|
||||
},
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
|
||||
expect(protocol.value).toBe('openai-completions')
|
||||
fireEvent.change(protocol, { target: { value: 'anthropic-messages' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the protocol travels: every other stored field is unchanged, so no
|
||||
// op restates it.
|
||||
expect(firstMutate(mutate)).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects nothing for a declared route whose profile names no protocol', async () => {
|
||||
// A route hand-written into settings.yaml with no model needs no protocol
|
||||
// to resolve, so the card can be opened over one. The select must not read
|
||||
// as if that route had picked its first choice.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
|
||||
@@ -61,6 +61,29 @@ describe('ModelsSection theme styles', () => {
|
||||
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
|
||||
})
|
||||
|
||||
it('gives every dropdown the shared chevron instead of the OS arrow', () => {
|
||||
// `select.input` caps the control at 240px, and the OS arrow is painted
|
||||
// flush inside that shrunk right edge — visibly tighter than every other
|
||||
// control on the page. `.selectInput` is what removes it, reserves the
|
||||
// right pad, and paints the shared chevron; a `<select>` that takes
|
||||
// `.input` alone silently keeps the OS one.
|
||||
const sources = readdirSync(fileURLToPath(new URL('../src/client/', import.meta.url)))
|
||||
.filter(name => name.endsWith('.tsx'))
|
||||
.map(name => ({
|
||||
name,
|
||||
text: readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8'),
|
||||
}))
|
||||
const bare = sources.flatMap(({ name, text }) => text
|
||||
.split('<select')
|
||||
.slice(1)
|
||||
// The element's own attributes end at the first `>`; a child `<option>`
|
||||
// carries no className of its own and must not answer for the select.
|
||||
.map(rest => rest.slice(0, rest.indexOf('>')))
|
||||
.filter(attributes => !attributes.includes('selectInput'))
|
||||
.map(() => name))
|
||||
expect(bare).toEqual([])
|
||||
})
|
||||
|
||||
it('never falls back to a literal colour', () => {
|
||||
// A token that resolves is never the problem; an undeclared one takes this
|
||||
// branch, and a literal here is a single colour for both themes.
|
||||
|
||||
Reference in New Issue
Block a user