feat(ui-models): tag the provider rows this deployment declared

A row's stored profile could not tell a hand-declared gateway from a
shipped provider whose models someone narrowed — both look identical from
outside the adapter — so the Models page had no way to mark the routes a
deployment added itself.

The directory entry now carries `declared`, answered by the owning adapter
against its own installed catalog, and the page renders a Custom tag from
it. Absence stays "this adapter draws no such distinction" rather than
"shipped", so a route no adapter claims is labelled neither way.

Also records the default-route work's Agent Note and the e2e evidence for
all three changes: the composer switch writing the section, and the Models
page declaring a route with its own reasoning effort.
This commit is contained in:
Yichen Jiang
2026-08-07 13:49:47 +08:00
parent e0f9f7a6e6
commit 9679597204
38 changed files with 479 additions and 56 deletions

View File

@@ -2500,8 +2500,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
providers: request => ok(request, {
providers: [
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
// One hand-declared route, so a surface reading this fixture meets
// the tagged shape rather than only the shipped one.
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
],
}),
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),

View File

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

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. 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. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
@@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened.
## Model Experience

View File

@@ -4,7 +4,7 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `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。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
@@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。
## 模型体验

View File

@@ -72,6 +72,20 @@
color: var(--dsw-alias-label-primary);
}
/* Reads as an annotation on the name, not as a second name: caption size and
the secondary label tone, so it never competes with the row's own title.
`rowActions` keeps the `margin-left: auto`, which is what holds the tag
beside the name instead of letting it drift across the row. */
.rowTag {
flex: none;
padding: 1px 6px;
border: 1px solid var(--dsw-alias-border-l3);
border-radius: 4px;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
}
.rowActions {
display: inline-flex;
align-items: center;

View File

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

View File

@@ -65,6 +65,7 @@ export const en = {
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
customTag: 'Custom',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
@@ -149,6 +150,7 @@ export const zh: typeof en = {
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customTag: '自定义',
customRoute: 'Provider ID',
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '只能使用小写字母、数字和短横线。',

View File

@@ -66,6 +66,8 @@ function scriptedFace(options: {
providers?: Record<string, unknown>
/** User layer, when it differs from the effective section. */
userProviders?: Record<string, unknown>
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
declaredRoutes?: readonly string[]
discover?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
@@ -86,6 +88,7 @@ function scriptedFace(options: {
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', provider],
active: true,
declared: options.declaredRoutes?.includes(provider) ?? false,
})),
}))),
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
@@ -599,6 +602,53 @@ describe('endpoint interrogation', () => {
})
})
describe('provider rows', () => {
it('tags the routes the adapter declared, and only those', async () => {
await mountSection({
providers: {
openai: { apiKeyEnv: 'OPENAI_API_KEY' },
'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' },
},
declaredRoutes: ['acme-gateway'],
})
const rowOf = (provider: string): HTMLElement => {
const row = screen.getByText(provider).closest('li')
if (row === null) throw new Error(`no row for ${provider}`)
return row
}
expect(rowOf('acme-gateway').textContent).toContain(en.customTag)
// `openai` carries a stored profile too — the tag follows the adapter's
// catalog, not the presence of settings, so it stays off here.
expect(rowOf('openai').textContent).not.toContain(en.customTag)
})
it('shows no tag when the adapter draws no catalog distinction', async () => {
const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({
providers: [{
provider: 'openai',
displayName: 'openai',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai'],
active: true,
}],
}))) as never
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
await controller.load()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={scripted.face as never}
t={t}
/>)
// Absent is "unknown", never "shipped": an adapter that answers nothing
// must not have its routes labelled either way.
expect(screen.queryByText(en.customTag)).toBeNull()
})
})
describe('hand-declared providers', () => {
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
const scripted = scriptedFace()
@@ -683,7 +733,7 @@ describe('hand-declared providers', () => {
declare()
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning')
expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning')
})
it('names the blocked gate under the form, and nothing once it is satisfied', () => {

View File

@@ -2099,7 +2099,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'LlmConfigurableProvider',
declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n}',
declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n}',
},
{
name: 'LlmDiscoveredModel',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
README.md: 38f18995f2982db2c5a48971d7d044448e5adc8c
README.zh.md: c444ed6b7485b6ddca059c5edf7f30828bd96ab7

View File

@@ -2,7 +2,17 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, reasoningEffort?, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
## The default route (`api-gateway` settings section)
`{provider, model, reasoningEffort?}` is also the gateway's user-settings section, registered under `api-gateway`: the composition entry is the `base` layer and `settings.yaml` layers the user's own choice over it. `workspaceRoot` is deliberately outside the section — a launcher fact, not a preference.
A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created.
`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.
The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise.
## Contract layer (`/api`)

View File

@@ -2,7 +2,17 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包在设计上与传输方式无关不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, reasoningEffort?, workspaceRoot?}`,提供 `ctx.apiProxy`。该包在设计上与传输方式无关不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
## 默认路由(`api-gateway` 设置段)
`{provider, model, reasoningEffort?}` 同时是网关的用户设置段,注册在 `api-gateway` 之下:组合条目是 `base` 层,`settings.yaml` 把用户自己的选择叠加其上。`workspaceRoot` 刻意不在段内——它是启动器事实,不是偏好。
会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。
`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。
存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。
## 契约层(`/api`

View File

@@ -26,7 +26,8 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
@@ -2558,15 +2559,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const active = new Set(registered.map(provider => provider.id))
const directory = ctx.llm.listConfigurableProviders()
const declared = new Set(directory.map(entry => entry.provider))
const views = directory.map(entry => ({
const views: ConfigurableProviderView[] = directory.map(entry => ({
provider: entry.provider,
displayName: entry.displayName,
settingsNs: entry.settingsNs,
settingsPath: [...entry.settingsPath],
active: active.has(entry.provider),
...entry.declared === undefined ? {} : { declared: entry.declared },
}))
// Routes registered without a directory declaration still appear —
// they exist and serve models — just with no settings address.
// they exist and serve models — just with no settings address. No
// adapter claimed them, so nothing can say whether they are shipped.
for (const provider of registered) {
if (declared.has(provider.id)) continue
views.push({

View File

@@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({
settingsNs: z.string(),
settingsPath: z.array(z.string()),
active: z.boolean(),
declared: z.boolean().optional(),
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
/** llm.providers request payload. */

View File

@@ -22,6 +22,12 @@ export interface ConfigurableProviderView {
settingsPath: string[]
/** Whether the route is currently registered (its models are requestable). */
active: boolean
/**
* Whether the owning adapter knows this route only because configuration
* declared it. Absent when the adapter draws no such distinction, so a
* surface must treat absence as "unknown", not as "shipped".
*/
declared?: boolean
}
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */

View File

@@ -66,22 +66,23 @@ export interface Config extends DefaultRouteSettings {
workspaceRoot?: string
}
/**
* The default-route fields, as fresh schema instances. Both the plugin config
* and the settings section are built from this one call, so the section stays
* a subset of the config structurally rather than by a comment two people have
* to keep true.
*/
function defaultRouteFields(): { [K in keyof Required<DefaultRouteSettings>]: z<string> } {
return {
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
}
}
/** The config fields the settings section carries; the rest stay launcher-owned. */
const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const
/** Schema of the settings section. */
const DefaultRouteSchema: z<DefaultRouteSettings> = z.object(defaultRouteFields())
/**
* The settings section's schema, picked out of the plugin config rather than
* restated. The config stays a plain literal because the configuration-catalog
* generator reads it statically; picking from it is what keeps the section a
* subset of it as both evolve.
* @param config - the plugin config schema to pick from.
* @returns the section schema over {@link DEFAULT_ROUTE_FIELDS}.
*/
function defaultRouteSchema(config: z<Config>): z<DefaultRouteSettings> {
const fields = Object.fromEntries(
DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]),
)
return z.object(fields) as z<DefaultRouteSettings>
}
/** Project the stored/composed section onto the agent-facing target shape. */
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
@@ -106,7 +107,9 @@ export class ApiProxyService extends Service implements ApiProxy {
]
static Config: z<Config> = z.object({
...defaultRouteFields(),
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
workspaceRoot: z.string(),
})
@@ -135,7 +138,7 @@ export class ApiProxyService extends Service implements ApiProxy {
...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort },
}
let route: () => DefaultRouteSettings = () => entry
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, {
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, {
setSource: (current) => {
route = current
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: af0e952dd8dbd9767b98229ee6b87262007d6738
README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c
README.md: 3eee22198320dbbaf4b53e7d4339f1292f3fa36e
README.zh.md: 3871ab046147f57b442ea8018482080e04567150

View File

@@ -49,7 +49,7 @@ Configure credentials, the model catalog, and deployment-specific transport sett
maxTokens: 4096
```
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
## Catalog resolution

View File

@@ -49,7 +49,7 @@
maxTokens: 4096
```
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog也能寻址一条手工声明的路由。哪些适配器存在归组合面哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog也能寻址一条手工声明的路由。每个条目都带上 `declared`pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
## Catalog 解析

View File

@@ -92,11 +92,21 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
function directoryEntries(
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
): LlmConfigurableProvider[] {
const catalog = new Set(catalogProviderIds())
const entries = new Map<string, LlmConfigurableProvider>()
const declare = (provider: string, displayName: string): void => {
entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] })
entries.set(provider, {
provider,
displayName,
settingsNs: NS,
settingsPath: ['providers', provider],
// Membership of the installed catalog, not of the settings document:
// narrowing a shipped provider's models stores a profile too, and that
// route is still one pi-ai knows.
declared: !catalog.has(provider),
})
}
for (const provider of catalogProviderIds()) declare(provider, provider)
for (const provider of catalog) declare(provider, provider)
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
return [...entries.values()]
}

View File

@@ -124,13 +124,22 @@ describe('hand-declared providers', () => {
it('joins the configurable-provider directory so a settings surface can reach it', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
const directory = ctx.llm.listConfigurableProviders()
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
expect(directory).toContainEqual({
provider: 'acme-gateway',
displayName: 'Acme Gateway',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'acme-gateway'],
// Nothing in the installed catalog answers for this route, which is what
// configuration surfaces mark as a route this deployment declared.
declared: true,
})
// Membership of the catalog, not of the settings document: a shipped
// provider carries a stored profile the moment anyone corrects it.
expect(directory.filter(entry => entry.declared).map(entry => entry.provider))
.toEqual(['acme-gateway'])
expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false)
})
it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {

View File

@@ -68,6 +68,7 @@ describe('request-level dynamic profiles', () => {
displayName: 'openai',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai'],
declared: false,
})
await ctx.settings.update(NS, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd
README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972
README.md: 47fb73cd710a269bc0ac768ef475b56c99b98929
README.zh.md: b46ecf8ad1904630094b2c2b22ea421a406f4d79

View File

@@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. The handle also carries `replace(entries)`: the candidate set is validated in full before anything moves, so an entry another registration already declares leaves the current set intact, and an empty array is legal there. A plugin whose declared set follows its configuration must use `replace` rather than disposing and re-registering — the latter strands the directory empty whenever the new set is refused.
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant.
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant. An entry may carry `declared` — whether the owning adapter knows that route only because configuration named it. Only the adapter can answer, so absence means "this adapter draws no such distinction", never "shipped".
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber.
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works.
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` Ask one endpoint which models it advertises.

View File

@@ -13,7 +13,7 @@
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose资源释放。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。
- `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。条目可携带 `declared`——拥有该路由的适配器是否只因配置点名才知道它。只有适配器能回答,因此缺席意味着「这个适配器不作此区分」,绝不等于「内置」。
- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供「询问提供方端点」的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。
- `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace让界面只在可用之处提供该动作。
- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<LlmDiscoveredModel[]>` 询问某个端点它公布了哪些模型。

View File

@@ -137,6 +137,15 @@ export interface LlmConfigurableProvider {
* object; empty when the whole section is the profile.
*/
settingsPath: readonly string[]
/**
* Whether the owning adapter knows this route only because configuration
* declared it — a gateway or self-hosted server it ships nothing about.
* Absent means the adapter draws no such distinction; false means it does
* and this route is one of its own. Only the adapter can answer: a stored
* profile is how a user-added route AND a corrected shipped one both look
* from outside.
*/
declared?: boolean
}
/**