fix(web): answer the review on the declared-provider editor
The display name read only the user layer while its placeholder claimed a cleared field falls back to the route id. A `cordis.yml` can pin a name for a route the catalog does not ship, and clearing then restores that name, not the id. The placeholder now reads the composition layer — the same layer `inheritedModels` reads — and names the route id only when nothing pins one. The saved notice echoed the target captured when the card opened, which never lied while the name could not change. It now names the provider as the refreshed directory reports it, and is announced only once that refresh is in the snapshot it reads from. The protocol select's blank option is named, because a screen reader announces it either way and an unnamed one is a choice with no identity. `protocolChoices` no longer rehydrates the section schema for the two layouts that have no per-route protocol to find. Documentation caught up with the same broom: the module JSDoc and the e2e header both still described one field where the fold now carries two, the English user guide carried a Chinese UI label among English ones, and the README's DeepSeek row inventory omitted `maxTokens`.
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 packages/client/ui-models/README.md
|
||||
README.md: f86564151a3c3788c3002421cac465ef98177281
|
||||
README.zh.md: f94438db18a2034b4180f5b79ea521ad7037754c
|
||||
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), 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`; 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.
|
||||
|
||||
|
||||
@@ -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 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。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 页仍是诊断界面。
|
||||
|
||||
|
||||
@@ -184,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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,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
|
||||
@@ -239,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']}>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, DeepSeek's id/name/context-window model catalog, and the
|
||||
* wire protocol of a pi-ai route the adapter does not ship — the field the
|
||||
* create card asked that route for, editable here for the same reason).
|
||||
* 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
|
||||
@@ -151,7 +152,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
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`.
|
||||
const protocols = useMemo(() => protocolChoices(namespace), [namespace])
|
||||
// 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
|
||||
@@ -362,10 +368,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'displayName') ?? ''}
|
||||
// The route id, not the stored name: it is what this route
|
||||
// is called the moment the field is cleared, and clearing
|
||||
// is the only way back to it.
|
||||
placeholder={props.provider}
|
||||
// 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) }}
|
||||
@@ -405,8 +416,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
{/* 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. */}
|
||||
{probeApi === undefined ? <option value="" /> : null}
|
||||
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>
|
||||
|
||||
@@ -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 '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({})))
|
||||
@@ -759,6 +762,55 @@ describe('hand-declared providers', () => {
|
||||
.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.
|
||||
|
||||
Reference in New Issue
Block a user