mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL capability and the models under one provider disagree about which levels they accept: setting `anthropic` to `max` made six of its eight models throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches per provider, the whole provider vanished from the picker behind one error row. A provider-scoped control can only ever be set to a value some of its models reject. The composer's model picker already offers each model its own levels, and a switch there now records provider, model, and effort together as the next session's default — so the setting has a better home at the right granularity. The profile field stays in `settings.yaml` for a deployment that knows its route; only the control is gone, from both cards and both adapter families. Two `components.spec` cases used the control as the vehicle for their op assertions and now use `baseURL`, which is what they were actually testing. **A rejected Provider ID now reads as a fault.** It shared the neutral hint paragraph with the field's guidance, so the copy telling the user what they got wrong looked like advice. Reuses the existing `.error` style, matching the split the key field already makes.
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: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e
|
||||
README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20
|
||||
README.md: cf4e50630339c4055e9ae2df37246b814af06966
|
||||
README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f
|
||||
|
||||
@@ -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 literal key or 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. 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 literal key or 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 literal `apiKey` secret sidecar or 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. 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. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it.
|
||||
**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
|
||||
|
||||
|
||||
@@ -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 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 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 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」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 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog,而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它。
|
||||
**添加自定义提供方**用来声明 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` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -14,13 +14,11 @@
|
||||
* 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. A hand-declared model
|
||||
* carries no reasoning capability — pi-ai's installed catalog is what supplies
|
||||
* one, and it has nothing under this route — so a profile effort here makes
|
||||
* `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the
|
||||
* route, which drops the whole provider out of the model picker. The editor
|
||||
* card hides the control for the same reason once the directory reports the
|
||||
* route as declared.
|
||||
* 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'
|
||||
@@ -206,9 +204,11 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
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
|
||||
|
||||
@@ -54,8 +54,6 @@ interface EditorTarget extends ProviderIdentity {
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
/** Directory passthrough: the owning adapter ships nothing under this route. */
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
@@ -73,7 +71,6 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...target.declared === undefined ? {} : { declared: target.declared }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -140,7 +137,6 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
...row.entry.declared === undefined ? {} : { declared: row.entry.declared },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +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 —
|
||||
* withheld for a hand-declared route, whose models have no reasoning
|
||||
* capability to configure — 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 reads the redacted
|
||||
* descriptor, so it names only the fields it can see and a stored literal
|
||||
@@ -29,14 +31,12 @@ import {
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
|
||||
import type { EffortFamily } from './ReasoningEffortField.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||
type EditorLayout = EffortFamily | 'unknown'
|
||||
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
|
||||
|
||||
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
||||
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
@@ -57,13 +57,6 @@ export interface ProviderEditorProps {
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it. Such a route's models carry no reasoning capability, so the
|
||||
* effort control is withheld; absent means the adapter draws no such
|
||||
* distinction and the control shows.
|
||||
*/
|
||||
declared?: boolean
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
@@ -303,8 +296,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||
* unknown namespace never reaches this body.
|
||||
*/
|
||||
const curatedFields = (family: EffortFamily): ReactNode => {
|
||||
const effortField = EFFORT_FIELD[family]
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
@@ -361,21 +353,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* A hand-declared route's models carry no reasoning capability
|
||||
(pi-ai's installed catalog is what supplies one, and it has
|
||||
nothing under such a route), so a profile effort would make
|
||||
`resolveModel` throw for every model on it and drop the whole
|
||||
provider out of the picker. Offering the control at all would
|
||||
be offering a way to break the route. */}
|
||||
{props.declared === true ? null : (
|
||||
<ReasoningEffortField
|
||||
family={family}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
onChange={(effort) => { setField(effortField, effort) }}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* The provider-level reasoning-effort select: the profile's own default
|
||||
* effort, applied to every model on the route unless a request names one. The
|
||||
* empty option means "inherit", which on the wire is the field being absent
|
||||
* rather than an empty string.
|
||||
*
|
||||
* It carries the per-family vocabulary and field name so the editor's two
|
||||
* layouts cannot spell them differently. Only routes the adapter ships get
|
||||
* this control at all — a hand-declared model has no reasoning capability to
|
||||
* configure, and a profile effort over one makes its whole route fail to
|
||||
* resolve — so the create card renders nothing here by construction.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** The adapter families that expose a provider-level effort, and their vocabularies. */
|
||||
export type EffortFamily = 'deepseek' | 'pi-ai'
|
||||
|
||||
/** Reasoning vocabularies per family; the empty option means "inherit". */
|
||||
export const EFFORT_CHOICES: Record<EffortFamily, readonly string[]> = {
|
||||
deepseek: ['off', 'high', 'max'],
|
||||
'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
}
|
||||
|
||||
/** The profile key each family's effort lives under. */
|
||||
export const EFFORT_FIELD: Record<EffortFamily, string> = {
|
||||
deepseek: 'reasoningEffort',
|
||||
'pi-ai': 'reasoning',
|
||||
}
|
||||
|
||||
/** Props of {@link ReasoningEffortField}. */
|
||||
export interface ReasoningEffortFieldProps {
|
||||
/** Which vocabulary to offer. */
|
||||
family: EffortFamily
|
||||
/** Current value; the empty string is the inherit option. */
|
||||
value: string
|
||||
/** Receives the chosen effort, or undefined for inherit. */
|
||||
onChange: (effort: string | undefined) => void
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable the control (busy or read-only). */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the provider-level reasoning-effort select.
|
||||
* @param props - family vocabulary, current value, change sink, copy, and disabled state.
|
||||
* @returns the labelled select.
|
||||
*/
|
||||
export function ReasoningEffortField(
|
||||
{ family, value, onChange, t, disabled }: ReasoningEffortFieldProps,
|
||||
): ReactNode {
|
||||
return (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('effort')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={value}
|
||||
aria-label={t('effort')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { onChange(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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
@@ -130,8 +128,6 @@ export const zh: typeof en = {
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '提供方默认',
|
||||
effort: '推理强度',
|
||||
effortInherit: '默认',
|
||||
models: '模型目录',
|
||||
modelsInherited: '正在使用适配器默认模型',
|
||||
modelsCustomized: '已自定义模型目录',
|
||||
|
||||
@@ -92,13 +92,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: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
@@ -729,16 +728,16 @@ describe('ModelsSection', () => {
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
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,
|
||||
})
|
||||
})
|
||||
@@ -795,17 +794,16 @@ 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 — and the
|
||||
// profile's stored literal apiKey, absent from the redacted view the card
|
||||
// read, is named by nothing at all.
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them — and the profile's stored
|
||||
// literal apiKey, absent from the redacted view the card read, is named by
|
||||
// nothing at all.
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -705,35 +705,24 @@ describe('hand-declared providers', () => {
|
||||
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
|
||||
})
|
||||
|
||||
it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => {
|
||||
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' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
// A hand-declared model carries no reasoning capability — pi-ai's
|
||||
// installed catalog is what supplies one, and it ships nothing under this
|
||||
// route — so a profile effort makes `resolveModel` throw
|
||||
// UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole
|
||||
// provider out of the picker. Offering the control would be offering a way
|
||||
// to break the route.
|
||||
expect(screen.queryByLabelText(en.effort)).toBeNull()
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// The editor card withholds it for the same route for the same reason...
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(screen.queryByLabelText(en.effort)).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// ...and keeps it for a route the adapter actually ships, whose models do
|
||||
// carry the capability.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
const select = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect([...select.options].map(option => option.value))
|
||||
.toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
||||
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 () => {
|
||||
@@ -827,6 +816,20 @@ describe('hand-declared providers', () => {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user