Merge branch 'master' into feat/session-completed-dot
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -2502,6 +2502,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
// The fixture endpoint is imaginary, so the interrogation answers the
|
||||
// catalog it already serves — enough for a surface to exercise adopting
|
||||
// candidates without a reachable provider.
|
||||
discoverModels: request => ok(request, {
|
||||
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
|
||||
}),
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Same routing discipline as the host: rpcId first, then the payload's
|
||||
@@ -2619,6 +2625,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'credentials.unset': return this.api.credentials.unset(request)
|
||||
case 'llm.providers': return this.api.llm.providers(request)
|
||||
case 'llm.models': return this.api.llm.models(request)
|
||||
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,10 +44,15 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* reconnaissance no anonymous caller should have. `trustedHosts` is a
|
||||
* DNS-rebinding fence, explicitly not authentication, so the whole
|
||||
* configuration plane stays loopback-same-origin until a real authentication
|
||||
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
|
||||
* deliberately NOT here: it carries provider ids, display names, and model
|
||||
* lists — no endpoints, keys, or key state — and a LAN client's model picker
|
||||
* legitimately needs it.
|
||||
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
|
||||
* carries a draft credential, and it makes the HOST issue a GET to a URL the
|
||||
* caller chose and reports back the status or the parsed body — an anonymous
|
||||
* LAN caller would have a probe for whatever the host can reach and the
|
||||
* browser cannot.
|
||||
*
|
||||
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
|
||||
* it carries provider ids, display names, and model lists — no endpoints,
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
@@ -60,6 +65,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,6 +197,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -129,13 +129,15 @@ describe('connection node half', () => {
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// configuration plane, reads included, plus the one method that makes the
|
||||
// host fetch a caller-chosen URL. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -221,6 +223,9 @@ describe('connection node half over a real HTTP server', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -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: b55914197e472edec8a8b6d4d3e02036d1697728
|
||||
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec
|
||||
|
||||
@@ -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 — an empty capacity shows those fallbacks' magnitude as its placeholder, a hint rather than a mirror, since the field counts `K` as 1000 and a deployment may override them. 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.
|
||||
|
||||
@@ -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 会拒绝的值,配置留空的部分由适配器的路由级回退值定尺寸——留空的容量以这些回退值的量级作为占位符,那只是提示而非镜像:该字段按 1000 计 `K`,且部署可以覆盖这些回退值。不是正整数的容量根本不会被存下。
|
||||
|
||||
**获取可用模型**会针对表单**当前显示**的端点调用 `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 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal file
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal file
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal file
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* What an empty capacity field is worth, shown as its placeholder so a row left
|
||||
* blank does not read as a model with no capacity at all.
|
||||
*
|
||||
* The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s
|
||||
* `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person
|
||||
* would say them. They are a hint, not a mirror: this page counts `K` as 1000,
|
||||
* so typing `256K` stores 256000 while leaving the field blank keeps the
|
||||
* adapter's 262144. A deployment that overrides those defaults is not
|
||||
* reflected here — nothing on this page can read them.
|
||||
*/
|
||||
const CAPACITY_HINT: Readonly<Record<CapacityField, string>> = {
|
||||
contextWindow: '256K',
|
||||
maxTokens: '32K',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = (model: ModelDraft, index: number, field: CapacityField): string =>
|
||||
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, 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(model, index, 'contextWindow')}
|
||||
placeholder={CAPACITY_HINT.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(model, index, 'maxTokens')}
|
||||
placeholder={CAPACITY_HINT.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) }}
|
||||
/>
|
||||
{/* The id alone: it is the string adoption writes, and the
|
||||
capacities the endpoint reported are adopted with it and
|
||||
editable in the row that appears. */}
|
||||
<span className={styles['candidateId']}>{candidate.id}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -264,11 +264,26 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* Overrides the shared button base above: these two are not pills sitting in
|
||||
a footer but the last slot of the provider list, so they split the row
|
||||
evenly and repeat the row cards' corner. Dashed, like every other "nothing
|
||||
here yet" affordance on this page, to read as a place rather than a
|
||||
command. */
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
height: 44px;
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addCard,
|
||||
@@ -572,3 +587,44 @@ select.input {
|
||||
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(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -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,15 @@ 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)}`
|
||||
{
|
||||
// The same checker gates the submit button, so a card cannot reach this
|
||||
// with a bad row; it stays because the schema check below would refuse
|
||||
// the write with a message naming a path instead of the row, and because
|
||||
// nothing but this function decides what is written.
|
||||
const failure = validateDeepSeekModels(getPath(next, ['models']))
|
||||
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
|
||||
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 +286,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 +350,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 +386,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 this provider has available. Choose the ones to add.',
|
||||
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: '前往配置',
|
||||
|
||||
@@ -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
|
||||
|
||||
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal file
865
packages/client/ui-models/tests/provider-form.spec.tsx
Normal file
@@ -0,0 +1,865 @@
|
||||
// @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('spells a stored capacity back the way it is typed', async () => {
|
||||
await mountSection({
|
||||
providers: {
|
||||
openai: {
|
||||
baseURL: 'https://proxy.example/v1',
|
||||
models: [{ id: 'kept', contextWindow: 1_000_000, maxTokens: 256_000 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
openEditor('openai')
|
||||
expandModel(1)
|
||||
|
||||
// Opening a row reads the stored counts, which are plain integers; showing
|
||||
// them as such would make an already-configured route look unlike one the
|
||||
// user just typed, and re-applying would rewrite the field it read.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('256K')
|
||||
})
|
||||
|
||||
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,12 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
/**
|
||||
* 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 { readdirSync, 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')
|
||||
const tokens = readFileSync(
|
||||
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.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.
|
||||
// Every theme sheet, not just the platform tokens: font and scrollbar
|
||||
// variables are declared in siblings, and a gate reading one file would call
|
||||
// their names undeclared.
|
||||
const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
|
||||
.filter(name => name.endsWith('.css'))
|
||||
.map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
|
||||
.join('\n')
|
||||
|
||||
/** The declarations of one top-level rule, by selector. */
|
||||
function block(selector: string): string {
|
||||
@@ -21,12 +34,24 @@ describe('ModelsSection theme styles', () => {
|
||||
// resolves to whatever literal sits in its fallback slot, which is how this
|
||||
// section stayed light under the dark theme before. Undeclared names have
|
||||
// no fallback at all and inherit, so both spellings must fail here.
|
||||
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
|
||||
// Every theme-variable prefix the sheets actually use, not just `--dsw-`:
|
||||
// a `--dsh-` name reads as a plausible sibling and would otherwise slip
|
||||
// past this gate into a fallback literal.
|
||||
const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
|
||||
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
|
||||
expect(undeclared).toEqual([])
|
||||
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
|
||||
})
|
||||
|
||||
it('closes every block, so no rule is swallowed by the one above it', () => {
|
||||
// A missing `}` on an `@media` block is not a parse error: every rule after
|
||||
// it silently becomes conditional, and the whole fetch dialog once painted
|
||||
// unstyled for anyone whose system does not ask for reduced motion. Nothing
|
||||
// downstream reports this — the sheet loads and the classes still attach.
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
|
||||
})
|
||||
|
||||
it('separates the row card from the editor it expands into', () => {
|
||||
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
|
||||
// under the dark theme, so filling the row with either erases the nested
|
||||
@@ -35,4 +60,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)/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user