feat(web): declare a provider and its models from the Models page

The Models page could name a provider's key and little else. Adding an
OpenAI-compatible gateway meant opening $DSH_HOME/settings.yaml and
knowing the profile shape; correcting a stale context window meant the
same. This layer puts both on the page: a card that declares a route
pi-ai does not ship — id, endpoint, protocol, key, models — and a model
list on the pi-ai editor that can ask the provider what it serves and
adopt the answer.

It follows the DeepSeek catalog editor that landed in #1050 rather than
inventing a second look for the same job. Both editors now share the
section shell and heading, the danger-tinted delete, the add-model
button, the empty state, the per-row validator that names a bad row by
its position, and one K/M capacity vocabulary — 256K and 1M are read and
spelled back, while settings.yaml still stores plain token counts. The
row type is structurally open like that editor's, so a profile field
this card does not edit survives an edit here.

Three of that editor's decisions replaced weaker ones this branch had
made. Inheritance now reads the composition base rather than the
effective value, which would echo an override back the moment a reset
dropped it. Validation names the offending row instead of stating a
blanket problem. And emptying the list is no longer conflated with
handing the catalog back to the adapter — those are separate acts, with
separate affordances.

The create write carries the revision the card opened at, so a route
another tab declared meanwhile is a conflict rather than a silent
overwrite of its profile.
This commit is contained in:
Yichen Jiang
2026-08-06 15:18:17 +08:00
committed by imccyu
parent d97e150845
commit 44484ec5f6
21 changed files with 1982 additions and 60 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md
2026-08-04-declaring-a-provider-from-the-models-page.md: 53996e488c467e00754837b83b7a33994d4813ee
2026-08-04-declaring-a-provider-from-the-models-page.zh.md: fa61c48492eabf51f3d325078ceffa84ac52d12c
@@ -0,0 +1,43 @@
# Agent Note: Declaring a provider from the Models page
Status: implemented
English | [中文](2026-08-04-declaring-a-provider-from-the-models-page.zh.md)
## Problem
The two layers below made a pi-ai route [a declaration](2026-08-03-pi-ai-declared-provider-catalog.md) and gave the host a way to [interrogate a draft endpoint](2026-08-04-draft-provider-endpoint-interrogation.md). Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening `$DSH_HOME/settings.yaml` and knowing the profile shape, and correcting a stale context window meant the same. The capability existed and the surface did not expose it.
Two things were missing, and they are not the same shape. Editing an existing route's models is a *field* on a card that already exists. Declaring a route is a *create*: the route id is being chosen, so until it is chosen there is no settings address to edit.
## Decision
The model list is a component shared by both flows; the create is its own card.
`ModelListEditor` edits a profile's `models` array — one row per model with id, display name, context window, and output cap — and owns the fetch action. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing an optional field drops it rather than storing a value the schema would reject, and a capacity that is not a positive integer is not stored at all.
Fetching asks about the endpoint **the form currently shows** — a base URL edited but unsaved, a key typed but unstored — 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 rows that stay editable by hand.
`CustomProviderCard` declares a route pi-ai does not ship. It is a separate card because the route id is chosen here: 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. The three facts a hand-declared route cannot default — endpoint, protocol, and at least one model — gate the create button, so a failure names the field while the user is still looking at it.
The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones.
## Alternatives considered
**Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing.
**Add a wire field for the protocol list.** Explicit, and the obvious first instinct. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema.
**Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one.
**Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing.
## Consequences
A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list.
What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives.
## Testing
`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not.
@@ -0,0 +1,43 @@
# Agent Note: 在 Models 页上声明一个提供方
Status: implemented
[English](2026-08-04-declaring-a-provider-from-the-models-page.md) | 中文
## Problem
下面两层已经让 pi-ai 路由变成[一份声明](2026-08-03-pi-ai-declared-provider-catalog.md),并给了 host [询问草稿端点](2026-08-04-draft-provider-endpoint-interrogation.md)的能力。但两者都没有抵达不编辑 YAML 的人:Models 页仍然只为每个提供方提供一个 API 密钥输入框和一个装着 API 地址的折叠区,因此接入一个网关意味着打开 `$DSH_HOME/settings.yaml` 并知道 profile 的形状,更正一个过期的上下文窗口也是如此。能力已经存在,界面却没有暴露它。
缺的是两件事,而它们的形状并不相同。编辑既有路由的模型,是一张已经存在的卡片上的一个*字段*;声明一条路由则是一次*创建*:路由 id 正在此处被选定,而在选定之前根本没有可编辑的 settings 地址。
## Decision
模型列表是两条流程共用的组件;创建则是它自己的卡片。
`ModelListEditor` 编辑 profile 的 `models` 数组——一行一个模型,含 id、显示名称、上下文窗口与输出上限——并持有获取动作。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空某个可选字段会丢弃它,而不是存入一个 schema 会拒绝的值,不是正整数的容量则根本不会被存下。
获取会询问表单**当前显示**的端点——已修改但未保存的 API 地址、已键入但未存储的密钥——因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路;适配器自己的消息会出现在各行旁边,而这些行仍可手工编辑。
`CustomProviderCard` 声明 pi-ai 未提供的路由。它之所以是独立卡片,正因为路由 id 是在这里选定的:一次 `settings.mutate``providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的三件事——端点、协议、至少一个模型——会门控创建按钮,因此失败会在用户仍看着该字段时点名它。
协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。
## Alternatives considered
**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。
**为协议列表新增一个协议字段。** 显式,也是最直觉的第一反应。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。
**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。
**把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。
## Consequences
网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。
代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。
## Testing
`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。
@@ -21,3 +21,6 @@
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方
@@ -69,3 +69,6 @@
- button "添加提供方":
- img
- text: 添加提供方
- button "添加自定义提供方":
- img
- text: 添加自定义提供方
+1 -1
View File
@@ -15,7 +15,7 @@ export type {
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
@@ -25,7 +25,7 @@ export type {
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,
+2 -2
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: c578ecfc9163245e8666cb6d2d327efdaccccf89
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957
README.md: 9d9833cf269d6b2225605e5a4b095d291d97a6bd
README.zh.md: c19499a1e49a8d537a1722ab4dc4c4b6f0ee2026
+11 -1
View File
@@ -4,12 +4,20 @@ 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 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. 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 the direct DeepSeek adapter's advisory 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 a localized confirmation dialog must complete before the page submits that destructive unset.
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. 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. 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 the direct DeepSeek adapter's advisory 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 a localized confirmation dialog must complete before the page submits that destructive unset.
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.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation
A pi-ai profile's `models` list is edited on the card: one row per model showing its id and display name, with the context window and output cap behind a per-row disclosure and two label-free actions — expand and delete — on the right. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing a capacity drops it rather than storing a value the schema would reject, and the adapter's route-level fallbacks size whatever configuration leaves out. A capacity that is not a positive integer is simply not stored.
**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.
## Model Experience
None, as the section renders a browser configuration UI; nothing here reaches a model request.
@@ -22,4 +30,6 @@ None; this package neither assembles nor sends a provider request.
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
+11 -1
View File
@@ -4,12 +4,20 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek)或 `reasoning`pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek)或 `reasoning`pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问
pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,行上显示 id 与显示名称,上下文窗口与输出上限收在该行的展开区内,右侧是两个无文字的操作——展开与删除。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空容量会丢弃它,而不是存入一个 schema 会拒绝的值,配置留空的部分由适配器的路由级回退值定尺寸。不是正整数的容量根本不会被存下。
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。
## 模型体验
无。该分区渲染浏览器配置 UI;这里没有任何内容进入模型请求。
@@ -22,4 +30,6 @@
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
@@ -0,0 +1,240 @@
/**
* The card that declares a provider pi-ai does not ship — an OpenAI-compatible
* gateway, a self-hosted server, or a provider newer than the installed
* catalog.
*
* This is a create, not an edit, which is why it is its own card rather than
* the provider editor with extra fields: the route id is being *chosen* here,
* and the settings address does not exist until it is. One `settings.mutate`
* sets the whole profile at `providers.<route>`; the key travels separately
* through `credentials.set` under the reference the profile records, exactly as
* an existing provider's key does.
*
* The three fields a hand-declared route cannot default — endpoint, protocol,
* and at least one model — are required here rather than at load, so the
* failure names the field while the user is still looking at it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { EditorFooter } from './EditorFooter.tsx'
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import type { ModelDraft } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
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]+)*$/
/** Props of {@link CustomProviderCard}. */
export interface CustomProviderCardProps {
/** Route ids already declared, so the card refuses to shadow one. */
taken: readonly string[]
/** Wire protocols the adapter can serve, in the order it reports them. */
protocols: readonly string[]
/**
* Revision of the `llm-pi-ai` user section this card opened at, sent with
* the create so a route another tab declared meanwhile is a refusal rather
* than a silent overwrite of its whole profile.
*/
revision: number
/** Wire faces for the write and for interrogating the endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the card; `changed` reports whether a provider was created. */
onClose: (changed: boolean) => void
}
/**
* Render the custom-provider creation card.
* @param props - existing routes, protocol choices, wire faces, and copy.
* @returns the creation card.
*/
export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const { taken, protocols, api, t } = props
// Captured at mount, like the editor's: the write must be judged against the
// section this card was drafted over, not whatever it grew into meanwhile.
const [openedAt] = useState(() => props.revision)
const [route, setRoute] = useState('')
const [displayName, setDisplayName] = useState('')
const [baseURL, setBaseURL] = useState('')
const [protocol, setProtocol] = useState(protocols[0] ?? '')
const [keyDraft, setKeyDraft] = useState('')
const [models, setModels] = useState<readonly ModelDraft[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const disabled = props.readOnly || busy
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
const routeTaken = taken.includes(route)
// Rows are checked by the same per-row validator the editor cards use, so a
// bad row is named by its position here too. Capacities have route-level
// fallbacks; what a route cannot default is at least one model.
const modelFailure = validateDeepSeekModels(models)
const ready = route.length > 0 && !routeInvalid && !routeTaken
&& baseURL.length > 0 && models.length > 0 && modelFailure === 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
// nothing at all rather than printing an empty paragraph.
const hint = failure !== undefined || ready
? undefined
: baseURL.length === 0
? t('customNeedsBaseUrl')
: modelFailure !== undefined
? `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
: t('customNeedsModels')
/** 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 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 (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
// 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.
if (!stored.result.ok) return stored.result.error.message
}
return undefined
}
const create = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const outcome = await createOnce()
if (outcome !== undefined) {
setFailure(outcome)
return
}
props.onClose(true)
} catch (error) {
// A transport failure rejects rather than answering; without this the
// card would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
return (
<div className={styles['editor']}>
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{t('customTitle')}</span>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
<input
className={styles['input']}
type="text"
value={route}
placeholder="acme-gateway"
aria-label={t('customRoute')}
disabled={disabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
<p className={styles['advancedHint']}>
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
</p>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
className={styles['input']}
type="text"
value={displayName}
placeholder={route.length === 0 ? t('customDisplayName') : route}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setDisplayName(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
className={styles['input']}
type="text"
value={baseURL}
placeholder="https://gateway.example/v1"
aria-label={t('baseUrl')}
disabled={disabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={styles['input']}
value={protocol}
aria-label={t('customApi')}
disabled={disabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
<input
className={styles['input']}
type="password"
autoComplete="off"
value={keyDraft}
placeholder={t('keyPlaceholder')}
aria-label={t('keyInput')}
disabled={disabled}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
</div>
<ModelListEditor
models={models}
onChange={setModels}
probe={{
settingsNs: NS,
baseURL,
api: protocol,
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}}
api={api}
t={t}
disabled={disabled}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{/* Only the gates with something to say render; the route-id gate has its
own field-level hint, so its blocked state would print an empty line. */}
{hint === undefined ? null : <p className={styles['advancedHint']}>{hint}</p>}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || !ready}
submitLabel="create"
submitBusyLabel="creating"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void create() }}
/>
</div>
)
}
@@ -0,0 +1,65 @@
/**
* The action row every provider card ends with: dismiss on the left, commit on
* the right.
*
* The two cards commit different things — one creates a route, one edits an
* existing profile — but the row itself carries no such knowledge. It renders
* what it is handed, so the cards keep sole ownership of when a commit is
* allowed and what the in-flight wording is.
*
* Cancel refuses input only while a commit is in flight, never because the card
* is disabled: a card the deployment cannot write to must still be dismissable.
*
* @module dsh-client-ui-models/client/EditorFooter
*/
import type { ReactNode } from 'react'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Props of {@link EditorFooter}. */
export interface EditorFooterProps {
/** Localizer for the row's own labels. */
t: (key: keyof typeof en) => string
/** Whether a commit is in flight; holds Cancel and swaps the commit label. */
busy: boolean
/** Whether the commit is refused, as judged by the owning card. */
submitDisabled: boolean
/** Commit label while idle. */
submitLabel: keyof typeof en
/** Commit label while a commit is in flight. */
submitBusyLabel: keyof typeof en
/** Dismiss the card without committing. */
onCancel: () => void
/** Run the card's commit. */
onSubmit: () => void
}
/**
* Render one provider card's action row.
* @param props - the labels, commit gating, and handlers the owning card supplies.
* @returns the cancel/commit row.
*/
export function EditorFooter(props: EditorFooterProps): ReactNode {
const { t } = props
return (
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={props.busy}
onClick={props.onCancel}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={props.submitDisabled}
onClick={props.onSubmit}
>
{props.busy ? t(props.submitBusyLabel) : t(props.submitLabel)}
</button>
</div>
)
}
@@ -0,0 +1,441 @@
/**
* The model list of one pi-ai provider profile, plus the action that asks the
* provider what it serves.
*
* The list is the profile's `models` array as the card holds it: an empty list
* means "serve this route's built-in catalog", and any entry replaces that
* catalog, so a row is only ever added deliberately. Fetching asks the endpoint
* **the form currently shows** — including a key typed but not yet saved — so
* adding a provider is one pass instead of save-then-return; the reply is
* candidates the user picks from, never configuration written behind them.
*
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
* with no readable listing) is not a dead end: the failure is shown next to the
* rows the user can still fill in by hand.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
import { messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/**
* One configured model row. Structurally open, exactly like the DeepSeek
* catalog editor's rows: a profile field this card does not edit — one a future
* schema adds, or one hand-written in `settings.yaml` — has to survive being
* edited here rather than being dropped by a rebuild.
*/
export type ModelDraft = DeepSeekModelDraft
/** A row's text field, or the empty string when unset or not a string. */
function textOf(model: ModelDraft, key: string): string {
const value = model[key]
return typeof value === 'string' ? value : ''
}
/** A row's numeric field, or `undefined` when unset or not a number. */
function numberOf(model: ModelDraft, key: string): number | undefined {
const value = model[key]
return typeof value === 'number' ? value : undefined
}
/** What an interrogation needs, taken from the live form. */
export interface ProbeTarget {
/** Settings namespace whose adapter family answers. */
settingsNs: string
/**
* Route being edited, when the card edits one. An adapter that already
* describes it answers from its own registry, so such a card can ask without
* an endpoint at all.
*/
provider?: string
/** Endpoint as the form currently shows it. */
baseURL?: string
/** Wire protocol the form names, when it names one. */
api?: string
/** Key typed into the form and not yet stored, when there is one. */
apiKey?: string
}
/** Props of {@link ModelListEditor}. */
export interface ModelListEditorProps {
/** The rows as currently drafted. */
models: readonly ModelDraft[]
/** Whether the user layer currently owns the whole array; absent on a create. */
overridden?: boolean
/** Replace the drafted rows. */
onChange: (models: ModelDraft[]) => void
/** Remove the user-owned array and return to inheritance; absent on a create. */
onReset?: () => void
/** Endpoint facts for the fetch action. */
probe: ProbeTarget
/** Wire face the fetch action calls. */
api: Pick<IApiClient, 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every control (read-only deployment or a pending write). */
disabled: boolean
}
/** Disclosure chevron; rotates to point down while its row is open. */
function IconChevron({ open }: { open: boolean }): ReactNode {
return (
<svg
width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden
style={{ transform: open ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
>
<path d="M6 3.5L10.5 8L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/** Removal glyph for one model row. */
function IconTrash(): ReactNode {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9a1 1 0 001 .9h4.6a1 1 0 001-.9L12 4M6.5 6.8v4.4M9.5 6.8v4.4"
stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
)
}
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/**
* Spell a stored count for a field that may be unset. The spelling itself is
* {@link formatCapacity}, shared with the DeepSeek catalog editor so both
* surfaces read and write one K/M vocabulary.
* @param value - stored capacity, or `undefined` for an unset field.
* @returns the field text, empty when unset.
*/
function capacitySpelling(value: number | undefined): string {
return value === undefined ? '' : formatCapacity(value)
}
/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
function adopt(candidate: DiscoveredModelView): ModelDraft {
return {
id: candidate.id,
...candidate.name === undefined ? {} : { name: candidate.name },
...candidate.contextWindow === undefined ? {} : { contextWindow: candidate.contextWindow },
...candidate.maxTokens === undefined ? {} : { maxTokens: candidate.maxTokens },
}
}
/**
* Render the model list with its fetch action.
* @param props - the drafted rows, probe target, wire face, and copy.
* @returns the model-list editor.
*/
export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const { models, onChange, probe, api, t, disabled } = props
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const [candidates, setCandidates] = useState<readonly DiscoveredModelView[] | undefined>(undefined)
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
// Rows carry an id and a name; capacities are the exception, so they stay
// folded until asked for rather than crowding every row with four inputs.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
// Capacities are edited as text, so a field's keystrokes are held here rather
// than re-derived from the parsed count on every change — that would rewrite
// `1000` to `1K` mid-word. Unreadable text is kept past blur so the refusal
// names a row the user can still see, which is why this is one entry PER
// FIELD: a single buffer would be displaced by editing any other field, and
// the abandoned one would render its stored NaN as the literal `NaN`.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(new Map())
/** Buffer key for one capacity field; the row half moves when rows do. */
const bufferKey = (index: number, field: CapacityField): string => `${String(index)}:${field}`
const editCapacity = (index: number, field: CapacityField, text: string): void => {
setEditing(current => new Map(current).set(bufferKey(index, field), text))
patch(index, { [field]: parseCapacity(text) })
}
/** What a capacity field shows: the buffer while typing, else the stored count. */
const capacityText = (index: number, field: CapacityField): string =>
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(models[index] ?? {}, field))
/** Drop one row's entries and shift the rows after it down, in one pass. */
const reindexOnRemove = (
current: ReadonlyMap<string, string>,
index: number,
): Map<string, string> => {
const next = new Map<string, string>()
for (const [key, value] of current) {
const at = Number(key.slice(0, key.indexOf(':')))
if (at === index) continue
// Only the row number moves; the field half of the key is untouched.
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value)
}
return next
}
const toggleExpanded = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
const patch = (index: number, next: Record<string, string | number | undefined>): void => {
onChange(models.map((model, at) => {
if (at !== index) return model
// Rebuilt rather than spread over: an emptied optional field has to leave
// the profile, not be stored as a value its schema would reject.
// Spread first so a field this card does not edit survives; an emptied
// optional field is then dropped rather than stored as a value its
// schema would reject.
const cleared = new Set(
Object.entries(next).filter(([, value]) => value === undefined || value === '').map(([key]) => key),
)
return Object.fromEntries(
Object.entries({ ...model, ...next }).filter(([key]) => !cleared.has(key)),
)
}))
}
const fetchModels = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const response = await api.llm.discoverModels({
settingsNs: probe.settingsNs,
...probe.provider === undefined ? {} : { provider: probe.provider },
...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
...probe.api === undefined ? {} : { api: probe.api },
...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
})
if (!response.result.ok) {
setFailure(response.result.error.message)
return
}
const found = response.result.value.models
if (found.length === 0) {
setFailure(t('fetchEmpty'))
return
}
// Everything already configured starts unchecked, so adopting a
// selection never silently rewrites a capacity the user corrected.
const known = new Set(models.map(model => textOf(model, 'id')))
setCandidates(found)
setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
} catch (error) {
// The transport rejected rather than answering; without this the button
// would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
const closePicker = (): void => {
setCandidates(undefined)
setPicked(new Set())
}
const adoptPicked = (): void => {
/* v8 ignore next -- the dialog only renders with candidates loaded */
if (candidates === undefined) return
const byId = new Map(models.map(model => [textOf(model, 'id'), model]))
for (const candidate of candidates) {
if (!picked.has(candidate.id)) continue
// A row the user already tuned wins over the provider's own numbers.
// Keyed by id, so a half-typed row whose id is still empty is not a
// match and the candidate joins as its own row — correct, since a row
// without an id is not yet a model and the create/apply gates refuse it.
byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate))
}
onChange([...byId.values()])
closePicker()
}
const toggle = (id: string): void => {
setPicked((current) => {
const next = new Set(current)
if (!next.delete(id)) next.add(id)
return next
})
}
// A route the adapter already describes answers without an endpoint; only a
// draft with neither has nothing to ask about.
const askable = probe.provider !== undefined || (probe.baseURL !== undefined && probe.baseURL.length > 0)
return (
<section className={styles['modelCatalog']} aria-label={t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{t('models')}</span>
{props.overridden === undefined
? null
: (
<span className={styles['modelCatalogMeta']}>
{props.overridden ? t('modelsCustomized') : t('modelsInherited')}
</span>
)}
</div>
{props.overridden === true && props.onReset !== undefined
? (
<button
type="button"
className={styles['linkButton']}
disabled={disabled}
onClick={props.onReset}
>
{t('resetModels')}
</button>
)
: null}
<button
type="button"
className={styles['linkButton']}
disabled={disabled || busy || !askable}
title={askable ? undefined : t('fetchNeedsBaseUrl')}
onClick={() => { void fetchModels() }}
>
{busy ? t('fetching') : t('fetchModels')}
</button>
</div>
{models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
{models.map((model, index) => (
<div key={index} className={styles['modelEntry']}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={textOf(model, 'id')}
placeholder={t('modelId')}
aria-label={`${t('modelId')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { id: event.target.value }) }}
/>
<input
className={styles['input']}
type="text"
value={textOf(model, 'name')}
placeholder={t('modelName')}
aria-label={`${t('modelName')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { name: event.target.value === '' ? undefined : event.target.value }) }}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${t('modelAdvanced')} ${index + 1}`}
aria-expanded={expanded.has(index)}
title={t('modelAdvanced')}
onClick={() => { toggleExpanded(index) }}
>
<IconChevron open={expanded.has(index)} />
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${t('removeModel')} ${index + 1}`}
title={t('removeModel')}
disabled={disabled}
onClick={() => {
onChange(models.filter((_model, at) => at !== index))
// Both stores are keyed by position, so every row after this
// one shifts down and would otherwise inherit its neighbour's
// state — a different row's capacities popping open, or its
// half-typed text appearing in another row's field.
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at < index) next.add(at)
else if (at > index) next.add(at - 1)
}
return next
})
setEditing(current => reindexOnRemove(current, index))
}}
>
<IconTrash />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelContextWindow')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(index, 'contextWindow')}
aria-label={`${t('modelContextWindow')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'contextWindow', event.target.value) }}
/>
</label>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelMaxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(index, 'maxTokens')}
aria-label={`${t('modelMaxTokens')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
/>
</label>
</div>
)
: null}
</div>
))}
<button
type="button"
className={styles['addModelButton']}
disabled={disabled}
onClick={() => { onChange([...models, { id: '' }]) }}
>
{t('addModel')}
</button>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<Modal
open={candidates !== undefined}
onClose={closePicker}
title={t('fetchTitle')}
closeLabel={t('close')}
description={t('fetchDescription')}
className={styles['fetchDialog'] as string}
footer={(
<>
<Button variant="outline" onClick={closePicker}>{t('cancel')}</Button>
<Button variant="outline" onClick={adoptPicked}>{t('fetchAdopt')}</Button>
</>
)}
>
<ul className={styles['candidateList']}>
{(candidates ?? []).map(candidate => (
<li key={candidate.id} className={styles['candidate']}>
<label className={styles['candidateLabel']}>
<input
type="checkbox"
checked={picked.has(candidate.id)}
onChange={() => { toggle(candidate.id) }}
/>
<span className={styles['candidateId']}>{candidate.id}</span>
{candidate.contextWindow === undefined
? null
: <span className={styles['candidateMeta']}>{candidate.contextWindow}</span>}
</label>
</li>
))}
</ul>
</Modal>
</section>
)
}
@@ -264,9 +264,21 @@
gap: 12px;
}
/* The two ways to gain a provider, as equal siblings spanning the same width
as the rows above. Wraps rather than shrinking below a legible label. */
.addActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.addButton {
/* Master's pill shape and glyph gap, sized to share the row equally so the
two ways to gain a provider read as siblings and line up with the rows
above rather than as two pills of different lengths. */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
align-self: flex-start;
}
@@ -571,4 +583,50 @@ select.input {
.customizedSummary::before {
transition: none;
}
.fetchDialog {
max-width: 520px;
/* The candidate list scrolls inside this dialog, an elevated surface, so the
scrollbar indirection is rebound here rather than on the scrolling child:
the elevation choice belongs with the surface and inherits down (see
ui-theme styles/scrollbar.css for the contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.candidateList {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 320px;
margin: 0;
overflow-y: auto;
padding: 0;
list-style: none;
}
.candidate {
border-radius: 6px;
}
.candidateLabel {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
}
.candidateId {
flex: 1 1 auto;
font-family: var(--dsh-font-mono, monospace);
font-size: 13px;
overflow-wrap: anywhere;
}
.candidateMeta {
color: var(--dsh-text-tertiary, #888);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
@@ -14,7 +14,8 @@ import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { messageOf, protocolChoices } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -27,7 +28,7 @@ export interface ModelsSectionInjected {
/** uSES subscription hook bound to the store. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials'>
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
@@ -118,10 +119,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [declaring, setDeclaring] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) void controller.load()
}
@@ -163,6 +166,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'))
return (
<div className={styles['section']}>
@@ -202,7 +209,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<button
type="button"
className={styles['secondaryButton']}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
onClick={() => {
// One card at a time: leaving `declaring` set would show
// the create card beside this editor, and closing either
// one discards the other's draft.
setDeclaring(false)
setAdding(false)
setEditing(open ? undefined : target)
}}
>
{t('edit')}
</button>
@@ -274,24 +288,55 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
/>
</div>
)
: (
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
)}
: declaring
? (
<div className={styles['addCard']}>
<CustomProviderCard
taken={state.rows.map(row => row.entry.provider)}
protocols={protocols}
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
</div>
)
: (
// One row for the two ways to gain a provider: adopt one the
// adapter already knows, or declare one it does not. Side by side
// and equal-width so they read as siblings and line up with the
// rows above, rather than two pills of different lengths.
<div className={styles['addActions']}>
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setDeclaring(false)
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
<button
type="button"
className={styles['addButton']}
disabled={protocols.length === 0 || !state.writable}
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
>
<IconPlusOutline16 size={14} />
{t('customAdd')}
</button>
</div>
)}
</div>
<Modal
open={deleteTarget !== undefined}
@@ -22,6 +22,8 @@ import {
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -56,8 +58,8 @@ export interface ProviderEditorProps {
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Wire faces for writes and for interrogating a provider endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
@@ -167,6 +169,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
}
// The model list is validated by the same per-row checker for both families,
// so a bad row is named by its position rather than by a blanket message.
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
// What the form currently shows, which is what an interrogation must ask:
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
const probeBaseURL = stringAt(draft, 'baseURL') ?? stringAt(fallback, 'baseURL')
const probe = {
settingsNs: namespace.ns,
// Naming the route lets an adapter that already describes it answer from
// its own registry — better metadata, no network call, no endpoint needed.
provider: props.provider,
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
...probeApi === undefined ? {} : { api: probeApi },
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
@@ -183,10 +201,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
{
const failure = validateDeepSeekModels(getPath(next, ['models']))
if (failure !== undefined) {
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
@@ -263,6 +281,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
const catalogProps = {
models,
overridden: modelsOverridden,
t,
disabled,
onChange: (next: Record<string, unknown>[]) => {
setDraft(current => setPath(current, ['models'], next))
},
onReset: () => { setDraft(current => deletePath(current, ['models'])) },
}
return (
<>
<div className={styles['field']}>
@@ -316,22 +345,20 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</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. */}
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
{...catalogProps}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
</div>
</details>
</>
@@ -354,24 +381,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
: curatedFields(layout)}
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={busy}
onClick={() => { props.onClose(false) }}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={disabled || layout === 'unknown'}
onClick={() => { void apply() }}
>
{busy ? t('applying') : t('apply')}
</button>
</div>
{modelFailure === undefined
? null
: (
<p className={styles['advancedHint']}>
{`${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`}
</p>
)}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
submitLabel="apply"
submitBusyLabel="applying"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void apply() }}
/>
</div>
)
}
@@ -52,6 +52,29 @@ export const en = {
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
modelCapacityInvalid: 'A capacity must be a number, optionally suffixed K or M.',
modelDuplicate: 'Each model ID may appear once.',
modelContextWindow: 'Context window',
modelMaxTokens: 'Max output tokens',
fetchModels: 'Fetch available models',
fetching: 'Asking the provider\u2026',
fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
fetchEmpty: 'The provider listed no models. Add them by hand.',
fetchTitle: 'Choose models to add',
fetchDescription: 'These are the models the provider reports. Choose the ones to add; you can still edit their capacities afterwards.',
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
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.',
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
customNeedsBaseUrl: 'A custom provider needs a base URL.',
customNeedsModels: 'A custom provider needs at least one model.',
create: 'Create provider',
creating: 'Creating\u2026',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingGoToSettings: 'Go to settings',
@@ -113,6 +136,29 @@ export const zh: typeof en = {
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
modelDuplicate: '每个模型 ID 只能出现一次。',
modelContextWindow: '上下文窗口',
modelMaxTokens: '最大输出 token',
fetchModels: '获取可用模型',
fetching: '正在询问提供方\u2026',
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
fetchTitle: '选择要添加的模型',
fetchDescription: '以下是提供方报告的模型。勾选要添加的项,添加后仍可修改其容量。',
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customRoute: 'Provider ID',
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '只能使用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
create: '创建提供方',
creating: '创建中\u2026',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',
+23 -1
View File
@@ -11,7 +11,13 @@ import type {
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form'
/**
* Any route key walks a dict schema to the same profile node, so the lookup
* names one that cannot collide with a configured route.
*/
const PROBE_ROUTE = '\u0000probe'
/** One provider row the page renders. */
export interface ProviderRow {
@@ -66,6 +72,22 @@ export function deriveKeyRef(provider: string): string {
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
}
/**
* The wire protocols a hand-declared route may name, read out of the owning
* namespace's own schema. This stays a schema read rather than a wire field so
* the choices the page offers cannot drift from the ones the adapter accepts:
* both come from the same `Config`.
* @param namespace - the namespace view whose schema declares the profile shape.
* @returns the protocol identifiers, or an empty list when the schema has none.
*/
export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] {
if (namespace === undefined) return []
const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined)
if (list?.type !== 'union' || list.list === undefined) return []
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
}
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
if (namespace === undefined) return undefined
@@ -0,0 +1,846 @@
// @vitest-environment jsdom
/** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
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 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 { en } from '../src/client/locales.ts'
afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
/** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */
const PiAiConfig = Schema.object({
providers: Schema.dict(Schema.object({
apiKey: Schema.string().role('secret'),
apiKeyEnv: Schema.string().role('credential-ref'),
displayName: Schema.string(),
api: Schema.union(PROTOCOLS),
baseURL: Schema.string(),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
contextWindow: Schema.number(),
maxTokens: Schema.number(),
})),
reasoning: Schema.union(['off', 'high']),
})),
})
let nextRpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
}
function fail<T>(message: string, code: string): RpcResponse<T> {
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } }
}
function piAiNamespace(
providers: Record<string, unknown>,
userProviders: Record<string, unknown> = providers,
): SettingsNamespaceView {
return {
ns: 'llm-pi-ai',
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
// `value` is the effective section; `user` is only the layer this page
// writes. They differ whenever a composition `base` supplies something.
value: { providers },
base: {},
user: { providers: userProviders },
applies: 'live',
secrets: [],
revision: 3,
}
}
function scriptedFace(options: {
providers?: Record<string, unknown>
/** User layer, when it differs from the effective section. */
userProviders?: Record<string, unknown>
discover?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
} = {}) {
const providers = options.providers ?? {
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
}
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
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({})))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
providers: Object.keys(providers).map(provider => ({
provider,
displayName: provider,
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', provider],
active: true,
})),
}))),
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: discover,
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))),
update: vi.fn(),
replace: vi.fn(),
mutate,
},
credentials: {
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
}))),
set,
unset: vi.fn(),
},
}
return { face, discover, mutate, set, namespace }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
/** The settings write one card produced, as the scripted face recorded it. */
interface MutateCall {
ns: string
expectedRevision?: number
ops: { op: string; path: string[]; value?: unknown }[]
}
/** The first interrogation payload; fails the case when nothing was asked. */
function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0]
if (call === undefined) throw new Error('no interrogation was recorded')
return call
}
/** The first recorded settings write; fails the case when nothing was written. */
function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined
if (call === undefined) throw new Error('no settings write was recorded')
return call
}
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
const scripted = scriptedFace(options)
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
controller,
useSnapshot: bindSnapshotSelector(controller.store),
api: scripted.face as never,
t,
}
render(<ModelsSection {...injected} />)
return scripted
}
/** Open the editor of one configured row and expand its customized fold. */
function openEditor(provider: string): void {
const row = screen.getByText(provider).closest('li')
if (row === null) throw new Error(`no row for ${provider}`)
fireEvent.click(within_(row, en.edit))
const summary = document.querySelector('summary')
if (summary === null) throw new Error('no customized fold')
fireEvent.click(summary)
}
/** Open one model row's advanced fold, where the capacities live. */
function expandModel(index: number): void {
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
}
/** The button carrying `label`, typed so its disabled/title state is readable. */
function buttonNamed(label: string): HTMLButtonElement {
const found = screen.getByText(label)
if (!(found instanceof HTMLButtonElement)) throw new Error(`"${label}" is not a button`)
return found
}
/** Click the button with `label` inside `scope`. */
function within_(scope: HTMLElement, label: string): HTMLElement {
const found = [...scope.querySelectorAll('button')].find(button => button.textContent === label)
if (found === undefined) throw new Error(`no "${label}" button`)
return found
}
describe('protocolChoices', () => {
it('reads the protocols out of the namespace schema and nothing else', async () => {
const { namespace } = scriptedFace()
expect(protocolChoices(namespace)).toEqual(PROTOCOLS)
expect(protocolChoices(undefined)).toEqual([])
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
expect(protocolChoices(plain)).toEqual([])
await Promise.resolve()
})
})
describe('model list editing', () => {
it('adds, edits, and removes rows without storing emptied optional fields', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
// Clearing an optional field must drop it rather than store an empty value.
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate)).toMatchObject({
ns: 'llm-pi-ai',
expectedRevision: 3,
ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536 }] }],
})
})
it('names a duplicate model id in the edit flow too', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'dup' }] } },
})
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'dup' } })
// The create card refuses this in place; an edited route must not have to
// learn it from the host's refusal instead.
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
expect(buttonNamed(en.apply).disabled).toBe(true)
expect(mutate).not.toHaveBeenCalled()
})
it('reads K and M suffixes and keeps the text the user typed', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '1M' } })
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '32K' } })
// The field keeps the spelling rather than snapping to the expansion, and
// a plain count is not rewritten into a suffix mid-word either.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '1000' } })
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('1000')
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
// What lands in settings is always a plain token count.
expect(firstMutate(mutate).ops[0]?.value)
.toEqual([{ id: 'm', contextWindow: 1_000_000, maxTokens: 1000 }])
})
it('refuses to apply while a capacity is unreadable', async () => {
const { mutate } = await mountSection()
openEditor('openai')
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: 'abc' } })
// Silently dropping it would store a route sized differently from what the
// field shows, so the text stays put and the write is refused instead.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('abc')
expect(screen.getByText(`${en.model} 1: ${en.modelMaxTokensInvalid}`)).toBeTruthy()
expect(buttonNamed(en.apply).disabled).toBe(true)
expect(mutate).not.toHaveBeenCalled()
})
it('edits one row of several and lets a cleared capacity leave the profile', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'first' }, { id: 'second' }] } },
})
openEditor('openai')
expandModel(2)
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 2`), { target: { value: '2048' } })
fireEvent.change(screen.getByLabelText(`${en.modelName} 2`), { target: { value: 'Second' } })
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '4096' } })
// Clearing it back to empty must drop the field, not store a zero.
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops[0]?.value).toEqual([
{ id: 'first' },
{ id: 'second', name: 'Second', maxTokens: 2048 },
])
})
it('shows the adapter defaults as inherited until an edit takes them over', async () => {
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
openEditor('openai')
// The user layer names no models, so the list belongs to the adapter and
// says so; taking it over is an explicit act, not a side effect of opening.
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.queryByText(en.resetModels)).toBeNull()
})
it('keeps expansion on the row it belongs to after an earlier one is removed', async () => {
await mountSection({
providers: {
openai: {
baseURL: 'https://proxy.example/v1',
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
},
},
})
openEditor('openai')
// Expansion is keyed by position, so removing an earlier row shifts the
// rest down; without reindexing, row 3 would inherit row 2's open state.
expandModel(2)
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
// 'second' now sits at position 1 and keeps its capacities open; 'third'
// moved to position 2 and stays folded.
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('second')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
expect(screen.queryByLabelText(`${en.modelContextWindow} 2`)).toBeNull()
})
it('leaves an earlier row expanded and forgets the removed row\u2019s own state', async () => {
await mountSection({
providers: {
openai: {
baseURL: 'https://proxy.example/v1',
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
},
},
})
openEditor('openai')
// A row before the removal keeps its own position and stays open.
expandModel(1)
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
// Removing the expanded row itself drops that state rather than handing it
// to whichever row slides into the position.
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('third')
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
})
it('separates emptying the list from restoring the adapter defaults', async () => {
const { mutate } = await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept' }] } },
})
openEditor('openai')
// An empty override is a route that serves no models — a different intent
// from handing the catalog back, which is what the reset affordance does.
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
fireEvent.click(screen.getByText(en.resetModels))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops)
.toContainEqual({ op: 'unset', path: ['providers', 'openai', 'models'] })
})
})
describe('capacity spellings', () => {
it.each([
['', undefined],
['65536', 65_536],
['256K', 256_000],
['1m', 1_000_000],
// A decimal multiple is exact in intent but not in binary floating point,
// so an integral result snaps back instead of landing a few ULPs high.
['2.3M', 2_300_000],
// Not an integral count: kept as written rather than silently rounded.
['1.0005K', 1000.5],
])('reads %j as %j', (text, expected) => {
expect(parseCapacity(text)).toBe(expected)
})
it.each(['abc', '12x', '1 000', '-5', ''])('refuses %j rather than guessing', (text) => {
const parsed = parseCapacity(text)
expect(parsed === undefined || Number.isNaN(parsed)).toBe(true)
})
it.each([
[1_000_000, '1M'],
[256_000, '256K'],
[65_536, '65536'],
// Never a spelling that would not survive being read back.
[0, '0'],
[1.5, '1.5'],
])('spells %j as %j', (value, expected) => {
expect(formatCapacity(value)).toBe(expected)
})
it('round-trips every spelling it produces', () => {
for (const value of [1_000_000, 256_000, 65_536, 4096, 1000]) {
expect(parseCapacity(formatCapacity(value))).toBe(value)
}
})
})
describe('endpoint interrogation', () => {
it('asks the endpoint the form shows, with a key that is not yet stored', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] })))
await mountSection({ discover })
openEditor('openai')
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'typed-not-saved' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://edited.example/v1' } })
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({
settingsNs: 'llm-pi-ai',
// The route is named, so an adapter that already describes it answers
// from its own registry rather than the endpoint.
provider: 'openai',
baseURL: 'https://edited.example/v1',
apiKey: 'typed-not-saved',
})
})
it('carries the protocol the profile already names', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [] })))
await mountSection({
discover,
providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } },
})
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({
settingsNs: 'llm-pi-ai',
provider: 'openai',
baseURL: 'https://proxy.example/v1',
api: 'openai-responses',
})
})
it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
const discover = vi.fn(() => Promise.resolve(ok({
models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }],
})))
const { mutate } = await mountSection({
discover,
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } },
})
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
// The already-configured row starts unchecked; the new one starts checked.
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
expect(boxes.map(box => box.checked)).toEqual([false, true])
fireEvent.click(screen.getByText(en.fetchAdopt))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
expect(firstMutate(mutate).ops[0]?.value).toEqual([
{ id: 'kept', contextWindow: 111 },
{ id: 'fresh', contextWindow: 4096, name: 'Fresh' },
])
})
it('keeps the rows editable when the provider cannot be interrogated', async () => {
const discover = vi.fn(() => Promise.resolve(
fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'),
))
await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(/answered 401; check the API key/)
// The failure is a detour, not a dead end: hand-entry is still offered.
expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy()
})
it('reports an empty listing and a rejected transport', async () => {
const empty = vi.fn(() => Promise.resolve(ok({ models: [] })))
await mountSection({ discover: empty })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchEmpty)
cleanup()
const rejected = vi.fn(() => Promise.reject(new Error('carrier down')))
await mountSection({ discover: rejected })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText('carrier down')
})
it('can be asked for a configured route even with no endpoint', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] })))
await mountSection({ discover, providers: { openai: {} } })
openEditor('openai')
// A route the adapter already describes needs no endpoint at all.
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
fireEvent.click(screen.getByText(en.fetchModels))
await waitFor(() => { expect(discover).toHaveBeenCalled() })
expect(firstProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
})
it('keeps the create card asking only once it has an endpoint', () => {
// A provider being declared has no route yet, so the endpoint is the only
// thing an interrogation could go on.
const scripted = scriptedFace()
render(
<CustomProviderCard
taken={[]} protocols={PROTOCOLS} revision={7} api={scripted.face as never}
t={t} readOnly={false} onClose={vi.fn()}
/>,
)
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
expect(buttonNamed(en.fetchModels).title).toBe(en.fetchNeedsBaseUrl)
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
fireEvent.click(screen.getByText(en.fetchModels))
// A provider being declared names no route, so only the endpoint travels.
expect(firstProbe(scripted.discover)).toEqual({
settingsNs: 'llm-pi-ai',
baseURL: 'https://acme.test/v1',
api: 'openai-completions',
})
})
it('folds a row\u2019s capacities away until they are asked for', async () => {
await mountSection({
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'only' }] } },
})
openEditor('openai')
// The row shows what identifies a model; capacities are the exception.
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
expandModel(1)
expect(screen.getByLabelText(`${en.modelContextWindow} 1`)).toBeTruthy()
expandModel(1)
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
})
it('closes the picker without adopting anything on cancel', async () => {
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] })))
const { mutate } = await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
const dialog = await screen.findByRole('dialog')
// The editor card carries a Cancel of its own; this one is the dialog's.
fireEvent.click(within_(dialog, en.cancel))
await waitFor(() => { expect(screen.queryByText(en.fetchTitle)).toBeNull() })
expect(mutate).not.toHaveBeenCalled()
})
it('toggles a candidate off and back on before adopting', async () => {
const discover = vi.fn(() => Promise.resolve(ok({
models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }],
})))
const { mutate } = await mountSection({ discover })
openEditor('openai')
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
const first = boxes[0] as HTMLInputElement
fireEvent.click(first)
fireEvent.click(first)
fireEvent.click(screen.getByText(en.fetchAdopt))
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
// A disclosed output cap rides along with the candidate that has one.
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }])
})
})
describe('hand-declared providers', () => {
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
const scripted = scriptedFace()
const onClose = vi.fn()
render(
<CustomProviderCard
taken={['openai']}
protocols={PROTOCOLS}
revision={7}
api={scripted.face as never}
t={t}
readOnly={false}
onClose={onClose}
{...overrides}
/>,
)
return { ...scripted, onClose }
}
it('writes the whole profile and the key under the derived reference', async () => {
const { mutate, set, onClose } = mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme Gateway' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/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: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(mutate)).toEqual({
ns: 'llm-pi-ai',
ops: [{
op: 'set',
path: ['providers', 'acme-gateway'],
value: {
displayName: 'Acme Gateway',
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{ id: 'acme-large', contextWindow: 65_536 }],
},
}],
// The section this card was drafted over: a route another tab declared
// meanwhile makes this a conflict rather than an overwrite.
expectedRevision: 7,
})
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
})
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
// Endpoint first: the gate names the one thing standing in the way.
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
// Satisfied: the shared line disappears rather than rendering empty.
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('refuses to create while a capacity is unreadable', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
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: 'acme-large' } })
expandModel(1)
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '64 KiB' } })
expect(screen.getByText(`${en.model} 1: ${en.modelContextInvalid}`)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
})
it('keeps each half-typed capacity with its own row across a removal', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
for (const [at, id] of [[1, 'first'], [2, 'second'], [3, 'third']] as const) {
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} ${String(at)}`), { target: { value: id } })
expandModel(at)
// Deliberately mid-word: the buffer exists so text like this survives.
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} ${String(at)}`),
{ target: { value: `${String(at)}.` } })
}
// Removing the middle row: the one before keeps its position and text, the
// one after moves down carrying its own, and the removed row's text goes.
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1.')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('third')
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('3.')
})
it('refuses two models sharing one id', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'same' } })
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'same' } })
// The adapter refuses a duplicate outright, so the form must not offer to
// write one.
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'other' } })
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('creates a model with no capacities, which the route\u2019s fallbacks size', async () => {
const { mutate, onClose } = mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
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: 'bare' } })
// A listing that discloses nothing but ids is enough to create a working
// provider; the adapter sizes what configuration leaves out.
expect(buttonNamed(en.create).disabled).toBe(false)
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ models: [{ id: 'bare' }] })
})
it('refuses to create until the route, endpoint, and a model are usable', () => {
mountCard()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'Acme Gateway' } })
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'openai' } })
expect(screen.getByText(en.customRouteTaken)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
// A model row with no id is not a model.
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
expect(buttonNamed(en.create).disabled).toBe(false)
})
it('surfaces a refused write and a rejected transport without closing', async () => {
const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected')))
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
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' } })
fireEvent.click(screen.getByText(en.create))
await screen.findByText('read-only settings')
expect(onClose).not.toHaveBeenCalled()
})
it('surfaces a rejected transport during create', async () => {
const rejecting = vi.fn(() => Promise.reject(new Error('carrier down')))
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
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' } })
fireEvent.click(screen.getByText(en.create))
await screen.findByText('carrier down')
expect(onClose).not.toHaveBeenCalled()
})
it('reports a stored profile whose key write was refused', async () => {
const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected')))
const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never })
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: 'k' } })
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 screen.findByText('credential is read-only')
expect(onClose).not.toHaveBeenCalled()
})
it('creates with the chosen protocol and no display name', async () => {
const { mutate, onClose } = mountCard()
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.customApi), { target: { value: 'anthropic-messages' } })
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(onClose).toHaveBeenCalledWith(true) })
// No display name configured means none stored; the route id is the name.
expect(firstMutate(mutate).ops[0]?.value).toEqual({
apiKeyEnv: 'ACME_API_KEY',
api: 'anthropic-messages',
baseURL: 'https://acme.test/v1',
models: [{ id: 'm' }],
})
})
it('offers no protocol when the namespace declares none', () => {
mountCard({ protocols: [] })
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
})
it('closes without writing on cancel, and honors a read-only deployment', () => {
const { onClose, mutate } = mountCard()
fireEvent.click(screen.getByText(en.cancel))
expect(onClose).toHaveBeenCalledWith(false)
expect(mutate).not.toHaveBeenCalled()
cleanup()
mountCard({ readOnly: true })
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
expect(buttonNamed(en.create).disabled).toBe(true)
})
it('closes the create card when an existing row is opened for editing', async () => {
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
// Two cards at once would each be closable by the other: whichever one is
// dismissed clears the shared state and discards the other's draft.
openEditor('openai')
expect(screen.queryByText(en.customTitle)).toBeNull()
})
it('reaches the card from the section and returns to the button on cancel', async () => {
await mountSection()
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
expect(screen.getByText(en.customTitle)).toBeTruthy()
fireEvent.click(screen.getByText(en.cancel))
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
})
})
@@ -1,8 +1,18 @@
/**
* Models section stylesheet contract, asserted against the CSS text on disk.
*
* The section paints in both themes, and a `--dsw-*` name the theme does not
* declare fails silently: the browser takes the `var()` fallback, so the sheet
* still renders and only the dark theme looks wrong. Checking the names against
* the sheet that declares them is what turns that into a test failure.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
// The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
// stay on the source plane rather than needing a build.
const tokens = readFileSync(
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
'utf8',
@@ -35,4 +45,10 @@ describe('ModelsSection theme styles', () => {
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
})
it('never falls back to a literal colour', () => {
// A token that resolves is never the problem; an undeclared one takes this
// branch, and a literal here is a single colour for both themes.
expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
})
})
+1 -1
View File
@@ -51,7 +51,7 @@ export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, Too
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'