Merge origin/master at f1402327fa

This commit is contained in:
Tianyi Cui
2026-08-07 23:57:23 +08:00
447 changed files with 20295 additions and 1680 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 06c60b8bf6e16f3aeab422b12851cf7d39b13ab6
README.zh.md: 5ff458820a5da225a0ebd05e91f3e55a8cb764b8
README.md: 66f23d3f77adae23fcebd3adbb6c5c47ce16ae2f
README.zh.md: dcb35c624aca73b558532210750f684e5cc54e85

View File

@@ -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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. 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.
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 took the whole provider out of the model picker. 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.
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.
@@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels.
## Model Experience

View File

@@ -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 的占位符显示公共端点),另有 `reasoningEffort`deepseek`reasoning`pi-ai以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
行是*已配置*的提供方(其 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——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
@@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。
## 模型体验

View File

@@ -13,6 +13,12 @@
* 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
* failure names the field while the user is still looking at it.
*
* There is deliberately no reasoning-effort control, here or on the editor
* card: effort 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 model picker offers each model its own
* levels instead.
*/
import { useState } from 'react'
@@ -30,8 +36,15 @@ import styles from './ModelsSection.module.css'
/** The settings namespace a hand-declared provider is written into. */
const NS = 'llm-pi-ai'
/** A route id usable as a settings key and as the stem of a credential name. */
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/**
* A route id usable as a settings key AND as the stem of a credential name.
* The leading letter is the second half of that: `deriveKeyRef` uppercases the
* id and replaces every non-alphanumeric run with `_`, and a credential
* reference is a POSIX shell identifier, which cannot start with a digit. A
* digit-leading id passes every check this card makes and then fails at the
* credential seam with a raw regular expression the user cannot act on.
*/
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
/** Props of {@link CustomProviderCard}. */
export interface CustomProviderCardProps {
@@ -73,7 +86,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const [models, setModels] = useState<readonly ModelDraft[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
/**
* The profile write landed. Only the key write can still be outstanding, so
* the fields that describe the provider are settled and the retry path is
* the credential alone.
*/
const [committed, setCommitted] = useState(false)
const disabled = props.readOnly || busy
/** Everything but the key stops being editable once the provider exists. */
const profileDisabled = disabled || committed
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
const routeTaken = taken.includes(route)
@@ -89,14 +110,17 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const ready = route.length > 0 && !routeInvalid && !routeTaken
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
&& keyFailure === undefined
// The one blocked gate worth a line under the form. The route id is omitted
// because its own field already explains itself, and a satisfied card says
// The one blocked gate worth a line under the form. A satisfied card says
// nothing at all rather than printing an empty paragraph.
const hint = failure !== undefined || ready
// The key field prints its own failure directly beneath itself, so a card
// blocked only by the key stays silent here rather than answering with the
// next unmet gate — which is satisfied, and reads as a second, false fault.
|| keyFailure !== undefined
// Same for the route id, and it must be tested rather than assumed: the
// fallback arm below reads "no models yet", so an unmet route gate used to
// fall through to it and contradict the filled-in list right above.
|| route.length === 0 || routeInvalid || routeTaken
? undefined
: baseURL.length === 0
? t('customNeedsBaseUrl')
@@ -107,26 +131,38 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
/** Perform the create, returning a failure message or undefined. */
const createOnce = async (): Promise<string | undefined> => {
const keyRef = deriveKeyRef(route)
const profile = {
...displayName.length === 0 ? {} : { displayName },
apiKeyEnv: keyRef,
api: protocol,
baseURL,
models: models.map(model => ({ ...model })),
const storesKey = keyValue.length > 0
if (!committed) {
const profile = {
...displayName.length === 0 ? {} : { displayName },
// The profile names the conventional reference only when this card is
// about to store a key, matching the editor: a route declared with the
// key left blank keeps its provider-native auth path (a credential
// chain, ADC) instead of resolving a reference nothing ever sets.
...storesKey ? { apiKeyEnv: keyRef } : {},
api: protocol,
baseURL,
models: models.map(model => ({ ...model })),
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
// The provider now exists. A retry after the key write below fails must
// not re-run this mutate: the revision it holds is the one this write
// just superseded, so the Host would answer `settings-conflict` and the
// key could never be stored from this card at all.
setCommitted(true)
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
if (keyValue.length > 0) {
if (storesKey) {
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
// The profile landed; saying the key did not is the only honest report,
// and the row is now editable so the key can be entered again there.
// and the retry above now goes straight back to this write.
if (!stored.result.ok) return stored.result.error.message
}
return undefined
@@ -164,13 +200,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={route}
placeholder="acme-gateway"
aria-label={t('customRoute')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
<p className={styles['advancedHint']}>
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
</p>
{/* A rejected id reads as a fault, not as guidance — the same split the
key field below already makes between its failure and its hint. */}
{routeInvalid || routeTaken
? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p>
: <p className={styles['advancedHint']}>{t('customRouteHint')}</p>}
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
@@ -179,7 +217,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={displayName}
placeholder={route.length === 0 ? t('customDisplayName') : route}
aria-label={t('customDisplayName')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setDisplayName(event.target.value) }}
/>
</div>
@@ -191,7 +229,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={baseURL}
placeholder="https://gateway.example/v1"
aria-label={t('baseUrl')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
@@ -201,7 +239,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
className={styles['input']}
value={protocol}
aria-label={t('customApi')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
@@ -238,7 +276,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
api={api}
t={t}
disabled={disabled}
disabled={profileDisabled}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{/* Only the gates with something to say render; the route-id gate has its
@@ -250,7 +288,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
submitDisabled={disabled || !ready}
submitLabel="create"
submitBusyLabel="creating"
onCancel={() => { props.onClose(false) }}
onCancel={() => { props.onClose(committed) }}
onSubmit={() => { void create() }}
/>
</div>

View File

@@ -86,6 +86,20 @@
color: var(--dsw-alias-label-primary);
}
/* Reads as an annotation on the name, not as a second name: caption size and
the secondary label tone, so it never competes with the row's own title. It
sits inside `rowIdentity` with the credential dot, which is what keeps it
beside the name rather than drifting toward the actions. */
.rowTag {
flex: none;
padding: 1px 6px;
border: 1px solid var(--dsw-alias-border-l3);
border-radius: 4px;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
}
.credentialDot {
box-sizing: border-box;
display: inline-block;

View File

@@ -271,6 +271,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<div className={styles['rowHead']}>
<span className={styles['rowIdentity']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
{/* Only the adapter can tell a hand-declared route from a
shipped one it also has a stored profile for, so the tag
follows its answer and stays off when it gives none. */}
{row.entry.declared === true
? <span className={styles['rowTag']}>{t('customTag')}</span>
: null}
{credentialConfigured
? (
<span

View File

@@ -7,8 +7,12 @@
* 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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
* DeepSeek's id/name/context-window model catalog). Everything else stays
* both families and DeepSeek's id/name/context-window model catalog).
* 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
* model picker offers each model its own levels; `settings.yaml` keeps the
* profile field for a deployment that knows its route. Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card names only the fields it can
* see instead of rebuilding the whole subtree from a partial descriptor.
@@ -33,18 +37,6 @@ import styles from './ModelsSection.module.css'
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
/** Reasoning vocabularies per layout; the empty option means "inherit". */
const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
deepseek: ['off', 'high', 'max'],
'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
}
/** The draft key the effort select edits, per layout. */
const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
deepseek: 'reasoningEffort',
'pi-ai': 'reasoning',
}
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
@@ -302,7 +294,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
* unknown namespace never reaches this body.
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const effortField = EFFORT_FIELD[family]
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
@@ -359,23 +350,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('effort')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={stringAt(draft, effortField) ?? ''}
aria-label={t('effort')}
disabled={disabled}
onChange={(event) => {
setField(effortField, event.target.value === '' ? undefined : event.target.value)
}}
>
<option value="">{t('effortInherit')}</option>
{EFFORT_CHOICES[family].map(choice => (
<option key={choice} value={choice}>{choice}</option>
))}
</select>
</div>
{/* Both families edit the same rows through the same contract; only
the extras differ — DeepSeek's inherited capacities, pi-ai's
endpoint interrogation. */}

View File

@@ -35,8 +35,6 @@ export const en = {
customized: 'Customized settings',
baseUrl: 'Base URL',
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
@@ -75,9 +73,10 @@ export const en = {
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
customTag: 'Custom',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.',
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
@@ -129,8 +128,6 @@ export const zh: typeof en = {
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
@@ -169,9 +166,10 @@ export const zh: typeof en = {
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customTag: '自定义',
customRoute: 'Provider ID',
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '只能使用小写字母、数字和短横线。',
customRouteHint: '小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',

View File

@@ -89,13 +89,12 @@ function wireNamespaces(): SettingsNamespaceView[] {
value: {
apiKeyEnv: 'DEEPSEEK_API_KEY',
baseURL: 'https://base',
reasoningEffort: 'high',
defaultContextWindow: 1_000_000,
maxTokens: 256_000,
models: DEFAULT_DEEPSEEK_MODELS,
},
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
user: { reasoningEffort: 'high' },
user: { baseURL: 'https://base' },
applies: 'live',
secrets: [],
revision: 0,
@@ -718,16 +717,16 @@ describe('ModelsSection', () => {
// The old path rebuilt the whole user section to clear one inherited field.
const { replace, update, mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
expect(effort.value).toBe('high')
fireEvent.change(effort, { target: { value: '' } })
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
expect(url.value).toBe('https://base')
fireEvent.change(url, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(replace).not.toHaveBeenCalled()
expect(update).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
ops: [{ op: 'unset', path: ['baseURL'] }],
expectedRevision: 0,
})
})
@@ -784,15 +783,14 @@ describe('ModelsSection', () => {
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
expect(urls).toHaveLength(2)
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
// Only the edited field travels: apiKeyEnv, baseURL and headers were
// already stored with these values, so no op restates them.
// Only the edited field travels: apiKeyEnv and headers were already stored
// with these values, so no op restates them.
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
expectedRevision: 0,
})
})

View File

@@ -9,7 +9,7 @@ import { ModelsSection } 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'
import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts'
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -66,6 +66,8 @@ function scriptedFace(options: {
providers?: Record<string, unknown>
/** User layer, when it differs from the effective section. */
userProviders?: Record<string, unknown>
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
declaredRoutes?: readonly string[]
discover?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
@@ -86,6 +88,7 @@ function scriptedFace(options: {
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', provider],
active: true,
declared: options.declaredRoutes?.includes(provider) ?? false,
})),
}))),
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
@@ -599,9 +602,59 @@ describe('endpoint interrogation', () => {
})
})
describe('provider rows', () => {
it('tags the routes the adapter declared, and only those', async () => {
await mountSection({
providers: {
openai: { apiKeyEnv: 'OPENAI_API_KEY' },
'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' },
},
declaredRoutes: ['acme-gateway'],
})
const rowOf = (provider: string): HTMLElement => {
const row = screen.getByText(provider).closest('li')
if (row === null) throw new Error(`no row for ${provider}`)
return row
}
expect(rowOf('acme-gateway').textContent).toContain(en.customTag)
// `openai` carries a stored profile too — the tag follows the adapter's
// catalog, not the presence of settings, so it stays off here.
expect(rowOf('openai').textContent).not.toContain(en.customTag)
})
it('shows no tag when the adapter draws no catalog distinction', async () => {
const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({
providers: [{
provider: 'openai',
displayName: 'openai',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai'],
active: true,
}],
}))) as never
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
await controller.load()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={scripted.face as never}
t={t}
/>)
// Absent is "unknown", never "shipped": an adapter that answers nothing
// must not have its routes labelled either way.
expect(screen.queryByText(en.customTag)).toBeNull()
})
})
describe('hand-declared providers', () => {
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
const scripted = scriptedFace()
function mountCard(
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
wire: Parameters<typeof scriptedFace>[0] = {},
) {
const scripted = scriptedFace(wire)
const onClose = vi.fn()
render(
<CustomProviderCard
@@ -652,6 +705,140 @@ describe('hand-declared providers', () => {
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
})
it('scopes each card to fields a provider can actually own', async () => {
// Reasoning effort used to sit here. It is a per-MODEL capability and the
// models under one provider disagree about it, so a provider-scoped
// control could only be set to a value some of them reject — which took
// the whole provider out of the picker. The composer's model picker owns
// the choice, and a switch there records provider+model+effort together.
const fields = () => [...document.querySelectorAll('input,select')]
.map(el => el.getAttribute('aria-label')).filter(Boolean)
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
cleanup()
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
openEditor('openai')
fireEvent.click(screen.getByText(en.customized))
expect(fields()).toEqual([en.keyInput, en.baseUrl])
})
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
const { mutate, onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
// The profile landed; only the key failed. The card says so and stays open.
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
expect(onClose).not.toHaveBeenCalled()
expect(mutate).toHaveBeenCalledTimes(1)
// The key is stored trimmed, matching the editor.
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
// The provider exists now, so the fields describing it are settled and
// only the key can still be corrected.
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
// Re-running the profile write would carry the revision this card's own
// first write superseded, so the Host would answer settings-conflict and
// the key could never be stored from here at all.
expect(mutate).toHaveBeenCalledTimes(1)
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
})
it('reports the created provider when cancelled after its profile landed', async () => {
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
const { onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
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(screen.getByText('nope')).toBeTruthy() })
// Walking away leaves a real provider behind; reporting no change would
// leave the page without the row it now has.
fireEvent.click(screen.getByText(en.cancel))
expect(onClose).toHaveBeenCalledWith(true)
})
it('never contradicts a filled-in field with the next gate\u2019s copy', () => {
mountCard()
const routeField = screen.getByLabelText(en.customRoute)
fireEvent.change(routeField, { target: { value: '2' } })
fireEvent.change(screen.getByLabelText(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' } })
// The route field explains itself right under the input; the shared line
// must stay silent rather than falling through to "no models yet" while
// the list above plainly has one.
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
// Fixing the route hands the line back to the gate that is actually unmet.
fireEvent.change(routeField, { target: { value: 'acme' } })
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('refuses a route id whose derived credential reference would be illegal', () => {
mountCard()
const routeField = screen.getByLabelText(en.customRoute)
fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } })
// A digit-leading id used to pass every check this card makes and then
// fail at the credential seam with a raw regular expression: the
// reference derives as `123_API_KEY`, and a credential reference is a
// POSIX shell identifier, which cannot start with a digit.
fireEvent.change(routeField, { target: { value: '123' } })
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(routeField, { target: { value: 'a1' } })
expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
})
it('styles a rejected route id as a fault and its guidance as a hint', () => {
mountCard()
const routeField = screen.getByLabelText(en.customRoute)
// Same split the key field makes: what the user got wrong reads as a
// fault, what they have yet to do reads as guidance.
expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/)
fireEvent.change(routeField, { target: { value: '2' } })
expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/)
fireEvent.change(routeField, { target: { value: 'openai' } })
expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
})
it('derives a reference the credential seam accepts for every id it admits', () => {
// The two rules have to stay in step; this is the relation, checked
// directly rather than through the DOM.
const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) {
expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true)
}
})
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
@@ -815,8 +1002,10 @@ describe('hand-declared providers', () => {
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
// No display name configured means none stored; the route id is the name.
// No key typed means no reference either, matching the editor: the route
// keeps its provider-native auth path instead of resolving a reference
// nothing ever sets. The with-key case is covered above.
expect(firstMutate(mutate).ops[0]?.value).toEqual({
apiKeyEnv: 'ACME_API_KEY',
api: 'anthropic-messages',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm' }],