From 047ea509873aa2b891deb701924c19d7b35ec107 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:46:52 -0700 Subject: [PATCH 01/27] feat(web): add preview badge to empty hero --- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/EmptyHero.tsx | 3 +- .../src/client/skeleton/HeroShell.module.css | 31 ++++++++++++++++--- .../ui-conversation/tests/skeleton.spec.tsx | 11 ++++++- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 8611ac5c0d..bdb07876a3 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a9fb7901d7..8c5cf915dc 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..2f05223e5d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -33,6 +33,7 @@ export const zh = { 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', + 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'details.title': '详情', @@ -144,6 +145,7 @@ export const en = { 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', + 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'details.title': 'Details', diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 0c1b31bbb7..4b491e1f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -119,7 +119,8 @@ export function HeroShell({ t, children }: HeroShellProps) {
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - {t('hero.headline')} + {t('hero.headline')} + {t('hero.preview')}
{/* The resident composer (ConversationRoot wrapActiveBody seat; the diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 523f640c0b..a95007e381 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -23,21 +23,44 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ +/* Fish + title stay centered as one unit; the preview badge aligns with the + title's leading edge on the second row. */ .headline { - display: flex; + display: grid; + grid-template-columns: 34px auto; + column-gap: 10px; + row-gap: 4px; align-items: center; justify-content: center; - gap: 10px; font-size: 26px; line-height: 32px; font-weight: 500; color: var(--dsw-alias-label-primary); } +.headlineText { + grid-row: 1; + grid-column: 2; +} + +.previewBadge { + grid-row: 2; + grid-column: 2; + justify-self: start; + padding: 0 4px; + border-radius: 4px; + background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-business-primary); + font-size: 12px; + line-height: 18px; + font-weight: 500; + white-space: nowrap; +} + /* figma fish fill rides business blue. */ .fish { - flex: none; + grid-row: 1; + grid-column: 1; color: var(--dsw-alias-state-business-primary); } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 312ec88642..506984bca4 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -12,12 +12,14 @@ import type { import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx' +import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { @@ -214,6 +216,12 @@ function mount( } describe('ConversationRoot resident composer', () => { + it('renders the English preview badge through the hero locale seat', () => { + const view = render() + expect(view.getByText('Let\'s start building')).toBeTruthy() + expect(view.getByText('Preview')).toBeTruthy() + }) + it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { const b = mount(conversationSnapshot()) const box = b.view.getByRole('textbox') @@ -273,6 +281,7 @@ describe('ConversationRoot resident composer', () => { expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') expect(b.view.getByText('开始构建吧')).toBeTruthy() + expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the // persistence mirror stays bound (ConversationSession mounts chrome-hidden From d6126c25f24fc56b6503816b0b170a4e39d7570e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 00:19:09 +0800 Subject: [PATCH 02/27] feat(llm): declare pi-ai providers instead of looking them up A pi-ai route had to name an installed catalog provider, served that catalog's models verbatim, and could override only the endpoint. An OpenAI-compatible gateway, a self-hosted server, or a model newer than the pinned pi-ai release was therefore unreachable, and a stale context window could not be corrected without upgrading the package. A route is now a declaration whose defaults come from the installed catalog. `catalog.ts` merges that catalog under the profile's own model entries, `provider.ts` builds the pi-ai Provider (reusing the catalog provider when the route keeps its protocol, so implementations this package cannot reconstruct keep working), and the adapter serves every operation from one `createModels()` collection. That also retires the `@earendil-works/pi-ai/compat` import, which pi-ai documents as a temporary entry point it deletes with its ModelManager migration. Credentials stay on the harness seam: the resolved key rides the request as pi-ai's highest-priority auth override, so `Models` holds no credential store and a named-but-missing reference still fails loud instead of falling back to an unrelated ambient key. A model's configured maxTokens now reaches the seam as defaultMaxTokens. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 6 + ...6-08-03-pi-ai-declared-provider-catalog.md | 47 +++ ...8-03-pi-ai-declared-provider-catalog.zh.md | 47 +++ docs/config-catalog.md | 32 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 52 ++- packages/llm/llm-pi-ai/README.zh.md | 52 ++- packages/llm/llm-pi-ai/src/adapter.ts | 127 +++++--- packages/llm/llm-pi-ai/src/catalog.ts | 193 +++++++++++ packages/llm/llm-pi-ai/src/config.ts | 120 +++++-- packages/llm/llm-pi-ai/src/index.ts | 83 +++-- packages/llm/llm-pi-ai/src/provider.ts | 155 +++++++++ packages/llm/llm-pi-ai/tests/adapter.spec.ts | 6 +- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 302 ++++++++++++++++++ .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 83 +++-- 15 files changed, 1161 insertions(+), 148 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md create mode 100644 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md create mode 100644 packages/llm/llm-pi-ai/src/catalog.ts create mode 100644 packages/llm/llm-pi-ai/src/provider.ts create mode 100644 packages/llm/llm-pi-ai/tests/catalog.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml new file mode 100644 index 0000000000..7fb32d2c29 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +2026-08-03-pi-ai-declared-provider-catalog.md: ef695f6e4c79725400ee39a2d40ead27d6559a8d +2026-08-03-pi-ai-declared-provider-catalog.zh.md: 13cfed574f646de07228da80b3e518e31fd1f50b diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md new file mode 100644 index 0000000000..ef695f6e4c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -0,0 +1,47 @@ +# Agent Note: pi-ai routes are declared providers, not catalog lookups + +Status: implemented + +English | [中文](2026-08-03-pi-ai-declared-provider-catalog.zh.md) + +## Problem + +`dsh-llm-pi-ai` treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (`resolveProfiles` rejected anything else), model listing returned `getBuiltinModels(provider)` verbatim, and request-time model resolution looked the id up in that same catalog and overrode only `baseURL`. Three consequences followed, and all three were dead ends rather than gaps: an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog could not be configured at all; a model the catalog had not caught up with failed with `UNKNOWN_MODEL` even against a correct endpoint; and a model's context window and output cap were whatever the pinned pi-ai release said, so a deployment could neither correct a stale value nor supply one for a model pi-ai had never described. Upgrading the package was the only way to move any of it. + +The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/compat`, an entry point whose own module documentation declares it a temporary compatibility surface — its catalog reads are `@deprecated`, and it is deleted when pi-ai finishes its `ModelManager` migration. The three configuration limits and the deprecated dependency have the same fix, because pi-ai's supported runtime (`createModels()` / `createProvider()`) is built around a provider being *declared* rather than looked up. + +## Decision + +A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: + +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, `reasoning`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning-level spellings, OpenAI-compatibility quirks, and model headers ride the installed entry, because restating them in configuration could not be validated. +- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. +- `adapter.ts` owns one `createModels()` collection, re-synced when resolution produces a new profile map, and serves `listModels`, `resolveModel`, and `stream` from it. A model's configured `maxTokens` becomes the seam's `defaultMaxTokens`, so a request naming no output cap now carries the configured one. + +Resolution fails loud and names the route and model at fault: a model the catalog does not describe needs an explicit `contextWindow` and `maxTokens`; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. + +The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. + +### Credentials stay outside pi-ai + +pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. + +`ModelsImpl.applyAuth` treats `options.apiKey` as the highest-priority auth override, short-circuiting resolution entirely. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. A catalog route reuses the installed provider's `auth`, which preserves its provider-native ambient discovery for a profile naming no credential. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. + +## Alternatives considered + +- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. +- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. +- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. +- **A runtime dynamic catalog** — `fetchModels` plus `ModelsStore`, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into `settings.yaml`. That action belongs to the configuration surface and is deferred with it; `settings.yaml` stays the single source of truth for what a route serves. + +## Consequences + +Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration, closing the case where a request carried no output cap at all. + +What it costs: `settings.yaml` grows for a declared route, because a model the catalog cannot default must state its own capacity. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. + +## Testing + +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, and every resolution failure that names a route or model. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md new file mode 100644 index 0000000000..13cfed574f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -0,0 +1,47 @@ +# Agent Note: pi-ai 路由是被声明的提供方,而不是 catalog 查表 + +Status: implemented + +[English](2026-08-03-pi-ai-declared-provider-catalog.md) | 中文 + +## Problem + +`dsh-llm-pi-ai` 把 pi-ai 包生成的 catalog 当成了可配置范围的边界。路由键必须点名一个已安装提供方(`resolveProfiles` 拒绝其余一切),模型列举原样返回 `getBuiltinModels(provider)`,请求期的模型解析又在同一份 catalog 里查这个 id、且只覆盖 `baseURL`。由此产生三个后果,而且三个都是死路而非缺口:OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,根本无法配置;catalog 尚未跟上的模型即便端点正确也会以 `UNKNOWN_MODEL` 失败;模型的上下文窗口与输出上限完全由锁定的 pi-ai 版本决定,部署既无法更正过期值,也无法为 pi-ai 从未描述过的模型补上。要动其中任何一条,只能升级依赖。 + +适配器还经 `@earendil-works/pi-ai/compat` 的 `streamSimple` 发起流式请求,而该入口自己的模块文档声明它是临时兼容面——其 catalog 读取标了 `@deprecated`,并会在 pi-ai 完成 `ModelManager` 迁移时被删除。这三条配置限制与这个废弃依赖的解法是同一个,因为 pi-ai 受支持的运行时(`createModels()` / `createProvider()`)正是围绕「提供方是被*声明*出来的,而非查出来的」建立的。 + +## Decision + +提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: + +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`、`reasoning`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。思考级别拼写、OpenAI 兼容性怪癖与模型标头沿用已安装条目,因为在配置里重述它们无法被校验。 +- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。 +- `adapter.ts` 持有一个 `createModels()` 集合,在解析产出新的 profile 映射时重新同步,并由它服务 `listModels`、`resolveModel` 与 `stream`。模型已配置的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求现在会携带已配置的那一个。 + +解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 + +可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 + +### 凭据留在 pi-ai 之外 + +pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 + +`ModelsImpl.applyAuth` 把 `options.apiKey` 视为优先级最高的 auth 覆盖,会整条短路掉解析。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。catalog 路由复用已安装提供方的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 + +## Alternatives considered + +- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 +- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 +- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 +- **运行时动态 catalog**——`fetchModels` 加 `ModelsStore`,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 `settings.yaml`。该动作属于配置界面,与之一并暂缓;`settings.yaml` 始终是「路由服务什么」的唯一事实源。 + +## Consequences + +配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在自配置流出,堵上了「请求完全不带输出上限」的情形。 + +代价是:声明式路由会让 `settings.yaml` 变长,因为 catalog 无法默认的模型必须自报容量。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 + +## Testing + +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通,以及每一种点名路由或模型的解析失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b381d1e3da..44b54620d8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -702,8 +702,22 @@ export interface PiAiProviderProfile { apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string - /** Override the selected catalog model's endpoint without changing its protocol metadata. */ + /** Name shown by configuration surfaces; defaults to the route key. */ + displayName?: string + /** + * Wire protocol every model on this route speaks. Omission keeps each + * installed catalog model's own protocol, which is why a catalog route needs + * no protocol at all; a route the catalog does not ship must name one. + */ + api?: string + /** Endpoint for this route's models; defaults to the installed catalog's endpoint. */ baseURL?: string + /** + * This route's model catalog. Omission serves the installed catalog for the + * route unchanged; an explicit list replaces it, each entry defaulting its + * unset fields from the installed model of the same id. + */ + models?: PiAiModelProfile[] /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ @@ -723,11 +737,25 @@ export interface PiAiProviderProfile { /** Provider-owned model-request retry policy; omission uses normal defaults. */ retryPolicy?: RetryPolicyConfig } + +/** One configured model entry: an id plus the catalog fields it overrides. */ +export interface PiAiModelProfile { + /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ + id: string + /** Display name for selectors; defaults to the catalog name, then the id. */ + name?: string + /** Maximum combined request and response context in tokens. */ + contextWindow?: number + /** Per-request output cap materialized when a caller omits one. */ + maxTokens?: number + /** Whether the model exposes reasoning; defaults to the catalog capability. */ + reasoning?: boolean +} ``` Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:98`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 7e616b5cd1..8f3d239ebf 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: 75b2136315aed758f18f7fe82afcd4903f4a7b98 -README.zh.md: ea67250549f1d23d48455fd185283b00183dd538 +README.md: e597eedeb4d6e0ebf402b5547f71c9aff370d3dd +README.zh.md: e28105f1253c138b9bb0baf5d00e0c7eba0d7b52 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 75b2136315..78d32b0555 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,19 +2,20 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` against that route's configured catalog. A route naming an installed pi-ai provider inherits its endpoint, wire protocol, and model catalog as defaults and overrides them field by field; a route pi-ai does not ship is declared outright, so an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog is configuration rather than a code change. -The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. +The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `supportedProtocols()`; profile resolution, catalog materialization, provider construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: + # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 @@ -26,16 +27,37 @@ Configure credentials and deployment-specific transport settings per provider, k initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 + # Catalog route with its catalog narrowed to one model and that model's + # capacity corrected; every unset field still comes from the catalog. anthropic: apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - openrouter: - apiKeyEnv: OPENROUTER_API_KEY - headers: - X-Deployment: production + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates 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.`), so configuration surfaces can offer the full catalog before any route exists. 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; an unknown model 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.`), 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')`. + +## Catalog resolution + +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, and `reasoning`; pricing and input modalities have no harness consumer and ride the installed entry or are absent, while reasoning-level spellings and OpenAI-compatibility quirks have no configuration surface at all because restating them cannot be validated. + +Resolution fails loud, naming the offending route and model, when a route cannot be served: a model the installed catalog does not describe needs an explicit `contextWindow` and `maxTokens`, and a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. `api` accepts the protocols in `supportedProtocols()` — pi-ai's own streaming API set — and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. + +`baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions. ## Dynamic configuration (settings + credentials) @@ -43,17 +65,21 @@ The adapter reads its profiles through a thunk **once per operation** instead of Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. +The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the configured one. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. ## Provider/model routing and replay -The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. +Each resolved route contributes one pi-ai `Provider` to the adapter's `createModels()` collection, and requests reach the provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. + +Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. + +The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response. @@ -109,7 +135,9 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). -- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. +- **Model discovery is configuration, not a provider query** — the route's catalog is whatever `settings.yaml` says; nothing fetches a provider's `/models` endpoint, so a model list is only as current as its last edit. A one-shot discovery action that offers a provider's live list for the user to adopt belongs to the configuration surface and is deferred with it. +- **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. +- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ea67250549..dee44a81e3 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,19 +2,20 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并针对该路由已配置的 catalog 解析 `GenerateOptions.model`。点名了已安装 pi-ai 提供方的路由会继承其端点、协议格式与模型 catalog 作为默认值,并逐字段覆盖;pi-ai 未提供的路由则整体声明出来,因此接入 OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,都属于配置而非改代码。 -包根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 +包(package)根入口导出 Cordis 插件契约、`PiAiAdapter` 与 `supportedProtocols()`;profile 解析、catalog 物化、提供方构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: + # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 @@ -26,16 +27,37 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 + # Catalog route with its catalog narrowed to one model and that model's + # capacity corrected; every unset field still comes from the catalog. anthropic: apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - openrouter: - apiKeyEnv: OPENROUTER_API_KEY - headers: - X-Deployment: production + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,因此配置界面可以在任何路由存在之前就提供完整 catalog。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## Catalog 解析 + +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens` 与 `reasoning`;定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席,而思考级别的协议拼写与 OpenAI 兼容性怪癖则完全没有配置面,因为重述它们无法被校验。 + +解析会失败得响亮,并点名出问题的路由与模型:已安装 catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`,catalog 未提供的路由则需要 `api`、`baseURL` 和非空的 `models` 列表。`api` 接受 `supportedProtocols()` 中的协议——即 pi-ai 自己的流式 API 集合——且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 + +`baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy;省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。 ## 动态配置(settings + credentials) @@ -43,17 +65,21 @@ 凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 -适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 +适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带已配置的那一个。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 ## 提供方/模型路由与回放 -所选 pi-ai catalog descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 +每条已解析路由都会向适配器的 `createModels()` 集合贡献一个 pi-ai `Provider`,请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 + +凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己失败得响亮的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 + +所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 成功的 assistant 响应会在自身持久提供方/模型溯源旁存储经版本化的无损 JSON 回放状态。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。 @@ -109,7 +135,9 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 +- **模型发现属于配置,不是提供方查询**:路由的 catalog 就是 `settings.yaml` 所写的内容;没有任何环节会去拉取提供方的 `/models` 端点,因此模型列表的新鲜度只到最近一次编辑为止。把提供方实时列表呈给用户采纳的一次性发现动作属于配置界面,与之一并暂缓。 +- **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 +- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 030592f74c..f43bef2649 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -1,17 +1,25 @@ /** * Generic pi-ai-backed implementation of the Harness LLM seam. * + * The adapter owns one pi-ai `Models` collection and keeps it in step with the + * resolved profiles: each route contributes the `Provider` its resolution built, + * so model lookup, protocol dispatch, and request auth all reach pi-ai through + * its supported runtime rather than the deprecated global compatibility entry. + * + * Credentials stay outside that collection. The harness resolves a route's key + * through its own seam and passes it as the request's `apiKey` option, which + * pi-ai treats as the highest-priority auth override — so `Models` never holds + * a credential store and the harness keeps its fail-loud reference semantics. + * * @module dsh-llm-pi-ai/adapter */ -import { streamSimple } from '@earendil-works/pi-ai/compat' -import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' -import { getSupportedThinkingLevels } from '@earendil-works/pi-ai' +import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, Model, ModelThinkingLevel, + MutableModels, SimpleStreamOptions, ThinkingLevel, } from '@earendil-works/pi-ai' @@ -40,29 +48,15 @@ export interface PiAiAdapterOptions { profiles: () => ReadonlyMap /** * Resolve the credential for one already-resolved profile; called once per - * stream call and frozen for that call. `undefined` defers to pi-ai's - * provider-native ambient discovery, which the plugin allows only for a - * profile naming no credential at all; a named reference that misses throws - * `LlmError` `MISSING_CREDENTIAL` rather than falling back. + * stream call and frozen for that call. `undefined` defers to the route's own + * pi-ai auth, which for an installed catalog route is its provider-native + * ambient discovery; the plugin allows that only for a profile naming no + * credential at all, because a named reference that misses throws `LlmError` + * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise } -/** - * Resolve a catalog model dynamically and apply only the configured endpoint - * override, preserving the catalog's API/capability/compatibility metadata. - */ -function resolvePiModel( - profile: ResolvedPiAiProviderProfile, - modelId: string, -): Model { - const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined - if (model === undefined) { - throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') - } - return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL } -} - /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( profile: ResolvedPiAiProviderProfile, @@ -108,28 +102,65 @@ function requestHeaders(headers: Readonly> | undefined): } /** - * pi-ai-backed multi-provider adapter. Model descriptors are resolved for each - * request, so models need not be registered during the Cordis lifecycle. + * pi-ai-backed multi-provider adapter. Each operation reads the current + * profiles, so a configuration change reaches the next request without a + * restart; model descriptors come from the collection those profiles built. */ export class PiAiAdapter extends LlmAdapter { + private readonly models: MutableModels = createModels() + private registered: ReadonlyMap | undefined + constructor(private readonly config: PiAiAdapterOptions) { super() } + /** + * The `Models` collection for the current profiles. Resolution memoizes its + * result, so an unchanged configuration is recognized by identity and the + * collection is rebuilt only when the route set or any profile actually + * changes. + */ + private collection(): MutableModels { + const profiles = this.config.profiles() + if (profiles === this.registered) return this.models + this.models.clearProviders() + for (const profile of profiles.values()) this.models.setProvider(profile.piProvider) + this.registered = profiles + return this.models + } + + /** The profile for one route, or the seam's own not-owned failure. */ + private profileOf(provider: string): ResolvedPiAiProviderProfile { + const profile = this.config.profiles().get(provider) + if (profile === undefined) { + throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER') + } + return profile + } + + /** The configured descriptor for one exact route/model pair. */ + private modelOf(provider: string, model: string): Model { + this.profileOf(provider) + const resolved = this.collection().getModel(provider, model) + if (resolved === undefined) { + throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL') + } + return resolved + } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { return this.config.profiles().get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.config.profiles().get(provider) - if (profile === undefined) { - return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) - } - return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({ - provider, - id: model.id, - name: model.name, - }))) + return Promise.resolve().then(() => { + this.profileOf(provider) + return this.collection().getModels(provider).map(model => ({ + provider, + id: model.id, + name: model.name, + })) + }) } override resolveModel( @@ -137,15 +168,9 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.config.profiles().get(provider) - if (profile === undefined) { - return Promise.reject(new LlmError( - `pi-ai adapter does not own provider "${provider}"`, - 'NO_ADAPTER', - )) - } return Promise.resolve().then(() => { - const resolvedModel = resolvePiModel(profile, model) + const profile = this.profileOf(provider) + const resolvedModel = this.modelOf(provider, model) const levels = getSupportedThinkingLevels(resolvedModel) const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) return { @@ -153,6 +178,7 @@ export class PiAiAdapter extends LlmAdapter { id: model, name: resolvedModel.name, context: { contextWindow: resolvedModel.contextWindow }, + defaultMaxTokens: resolvedModel.maxTokens, reasoning: { efforts: levels.map(level => ({ id: ReasoningEffortId(level), @@ -170,14 +196,13 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - // One resolution per stream call: the profile snapshot and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. - const profile = this.config.profiles().get(options.provider) - if (profile === undefined) { - throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') - } - const model = resolvePiModel(profile, options.model) + // One resolution per stream call: the profile snapshot, the model + // descriptor, and the credential freeze here and hold for this whole + // request, so an in-flight stream never observes a configuration change and + // the next call re-resolves. + const profile = this.profileOf(options.provider) + const collection = this.collection() + const model = this.modelOf(options.provider, options.model) const reasoning = resolveReasoningLevel( model, options.reasoningEffort ?? profile.reasoning, @@ -192,7 +217,7 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = streamSimple(model, toPiContext(options), { + const events = collection.streamSimple(model, toPiContext(options), { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts new file mode 100644 index 0000000000..41e5527b39 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -0,0 +1,193 @@ +/** + * Materialization of one provider route's model catalog. The installed pi-ai + * catalog supplies defaults keyed by model id, and a profile's own model + * entries override them field by field, so a route naming a catalog provider + * stays configuration-free while a route pi-ai has never heard of is fully + * describable from `settings.yaml`. + * + * Every pi-ai `Model` field the harness cannot default is required here rather + * than at request time: an unserviceable route fails while its configuration is + * being resolved, which is the earliest point that can name the offending key. + * + * @module dsh-llm-pi-ai/catalog + */ + +import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' +import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' +import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai' + +/** + * Pricing for a model the installed catalog does not describe. The harness + * never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer + * reports spend — so this is the absence of a fact, not a configurable rate. + */ +const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + +/** + * Input modalities for a model the installed catalog does not describe. The + * request converter keeps only text blocks, so text is the adapter's actual + * capability rather than a deployment choice. + */ +const TEXT_ONLY: Model['input'] = ['text'] + +let providerIndex: Map | undefined + +/** + * Installed catalog providers by id, constructed once. Each entry owns the API + * implementations for its own models, which is why a catalog route reuses this + * provider instead of being rebuilt from parts. + * @returns the catalog provider index. + */ +function catalogProviders(): Map { + providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider])) + return providerIndex +} + +/** + * The installed catalog provider for one route, when pi-ai ships one. + * @param provider - provider route key. + * @returns the catalog provider, or `undefined` for a route pi-ai does not ship. + */ +export function catalogProvider(provider: string): Provider | undefined { + return catalogProviders().get(provider) +} + +/** + * Every provider route the installed pi-ai catalog ships. + * @returns the catalog provider ids. + */ +export function catalogProviderIds(): readonly string[] { + return getBuiltinProviders() +} + +/** + * The installed catalog models for one route, indexed by model id. + * @param provider - provider route key. + * @returns catalog models by id; empty for a route pi-ai does not ship. + */ +export function catalogModels(provider: string): Map> { + if (!catalogProviders().has(provider)) return new Map() + const models = getBuiltinModels(provider as BuiltinProvider) as Model[] + return new Map(models.map(model => [model.id, model])) +} + +/** One configured model entry: an id plus the catalog fields it overrides. */ +export interface PiAiModelProfile { + /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ + id: string + /** Display name for selectors; defaults to the catalog name, then the id. */ + name?: string + /** Maximum combined request and response context in tokens. */ + contextWindow?: number + /** Per-request output cap materialized when a caller omits one. */ + maxTokens?: number + /** Whether the model exposes reasoning; defaults to the catalog capability. */ + reasoning?: boolean +} + +/** The route-level facts model materialization reads. */ +export interface RouteCatalogRequest { + /** Provider route key, stamped onto every materialized model. */ + provider: string + /** Wire protocol override; absent defers to each catalog model's own API. */ + api?: string + /** Endpoint override; absent defers to the catalog model, then the catalog provider. */ + baseURL?: string + /** Configured catalog; absent means the whole installed catalog for this route. */ + models?: readonly PiAiModelProfile[] +} + +/** Report a route the deployment cannot serve, naming the settings key at fault. */ +function invalid(provider: string, detail: string): never { + throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`) +} + +/** + * The one wire protocol a catalog route's shipped models agree on. This is what + * lets a deployment add a model the installed catalog has not caught up with — + * a provider's newest release — without restating the protocol its siblings + * already use. A route whose shipped models disagree (an OpenAI-style catalog + * spanning Responses and Chat Completions) has no such answer, so a model it + * does not describe must name its protocol at the route. + */ +function sharedCatalogApi(defaults: ReadonlyMap>): string | undefined { + const apis = new Set() + for (const model of defaults.values()) apis.add(model.api) + return apis.size === 1 ? [...apis][0] : undefined +} + +/** + * Materialize one route's catalog by merging the installed catalog defaults + * under the configured entries. A route with no configured `models` serves the + * installed catalog unchanged, which is what keeps an existing + * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched. + * @param request - the route-level catalog facts. + * @returns the materialized models in configuration order. + */ +export function resolveRouteModels(request: RouteCatalogRequest): readonly Model[] { + const { provider } = request + const defaults = catalogModels(provider) + const providerBaseUrl = catalogProvider(provider)?.baseUrl + // An absent `models` key and an empty one are the same request: the config + // schema materializes `[]` for the absent case, and an empty catalog could + // serve no request anyway, so both mean "serve the installed catalog". + const configured = request.models ?? [] + const entries: readonly PiAiModelProfile[] = configured.length > 0 + ? configured + : [...defaults.values()].map(model => ({ id: model.id })) + if (entries.length === 0) { + invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + + ' must be listed in configuration') + } + const routeApi = sharedCatalogApi(defaults) + const seen = new Set() + return entries.map((entry) => { + if (entry.id.length === 0) invalid(provider, 'has a model with an empty id') + if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`) + seen.add(entry.id) + const base = defaults.get(entry.id) + const api = request.api ?? base?.api ?? routeApi + if (api === undefined) { + invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the` + + ' route\'s api to the wire protocol its endpoint speaks') + } + const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl + if (baseUrl === undefined) { + invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`) + } + const contextWindow = entry.contextWindow ?? base?.contextWindow + if (contextWindow === undefined) { + invalid(provider, `model "${entry.id}" needs a contextWindow; without it the harness cannot detect overflow` + + ' or size compaction') + } + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`) + } + const maxTokens = entry.maxTokens ?? base?.maxTokens + if (maxTokens === undefined) { + invalid(provider, `model "${entry.id}" needs a maxTokens; it is the output cap materialized into requests` + + ' that omit one') + } + if (!Number.isInteger(maxTokens) || maxTokens <= 0) { + invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`) + } + return { + id: entry.id, + name: entry.name ?? base?.name ?? entry.id, + api, + provider, + baseUrl, + reasoning: entry.reasoning ?? base?.reasoning ?? false, + input: base?.input ?? TEXT_ONLY, + cost: base?.cost ?? NO_COST, + contextWindow, + maxTokens, + // Catalog-only metadata: reasoning-level spellings and OpenAI-compatibility + // quirks have no configuration surface, so they ride the catalog entry or + // are absent for a model pi-ai has never described. + ...base?.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: base.thinkingLevelMap }, + ...base?.compat === undefined ? {} : { compat: base.compat }, + ...base?.headers === undefined ? {} : { headers: base.headers }, + } + }) +} diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index c635b1f13e..a1199ad471 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -3,29 +3,55 @@ * Profiles are a dict keyed by provider route, so the composition base and a * user-settings layer merge per provider and the route set is structural. * + * A route key is not required to name an installed pi-ai provider. When it does, + * that provider's endpoint, protocol, display name, and model catalog are the + * profile's defaults and the profile overrides them field by field; when it does + * not, the profile is the whole provider declaration. Resolution therefore ends + * in a built pi-ai `Provider` per route: everything a request needs is decided + * once, while the configuration key that made a route unserviceable can still be + * named in the failure. + * * @module dsh-llm-pi-ai/config */ -import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' -import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { resolveRouteModels } from './catalog.ts' +import type { PiAiModelProfile } from './catalog.ts' +import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +export type { PiAiModelProfile } from './catalog.ts' + /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string - /** Override the selected catalog model's endpoint without changing its protocol metadata. */ + /** Name shown by configuration surfaces; defaults to the route key. */ + displayName?: string + /** + * Wire protocol every model on this route speaks. Omission keeps each + * installed catalog model's own protocol, which is why a catalog route needs + * no protocol at all; a route the catalog does not ship must name one. + */ + api?: string + /** Endpoint for this route's models; defaults to the installed catalog's endpoint. */ baseURL?: string + /** + * This route's model catalog. Omission serves the installed catalog for the + * route unchanged; an explicit list replaces it, each entry defaulting its + * unset fields from the installed model of the same id. + */ + models?: PiAiModelProfile[] /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ @@ -47,15 +73,25 @@ export interface PiAiProviderProfile { } /** Validated profile with its route stamped and every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { - /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ +export interface ResolvedPiAiProviderProfile + extends Omit { + /** Harness route key and the `Models` collection key (the configuration dict key). */ provider: string + /** Resolved display name for selectors and configuration surfaces. */ + displayName: string /** Validated credential reference, when one is configured. */ apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy + /** + * The pi-ai provider this route registers, built from the resolved models. + * Construction happens here so an unserviceable protocol or an underspecified + * model fails with the rest of resolution, leaving the last good route set + * serving requests. + */ + piProvider: Provider } /** Plugin configuration: the provider routes this instance owns. */ @@ -75,10 +111,21 @@ const thinkingBudgets = z.object({ high: z.number(), }) +const modelProfile: z = z.object({ + id: z.string().required(), + name: z.string(), + contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), + reasoning: z.boolean(), +}) + const profile = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), + displayName: z.string(), + api: z.union(supportedProtocols()), baseURL: z.string(), + models: z.array(modelProfile), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), thinkingBudgets, @@ -95,11 +142,29 @@ export const Config: z = z.object({ providers: z.dict(profile).default({}), }) +/** Reject a pre-release profile shape, naming the replacement. */ +function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { + const legacy = source as PiAiProviderProfile & { + provider?: unknown + maxRetries?: unknown + maxRetryDelayMs?: unknown + } + if ('provider' in legacy) { + throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`) + } + if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { + throw new Error( + `llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;` + + ' compose agent recovery with dsh-llm-retry', + ) + } +} + /** - * Validate profiles against the installed pi-ai catalog and return a detached - * route-keyed map suitable for per-request reads. This is the one explicit - * resolve step, so an omitted dict resolves to the empty (dormant) route set - * here rather than through a hidden fallback. + * Validate profiles and return a detached route-keyed map suitable for + * per-request reads. This is the one explicit resolve step, so an omitted dict + * resolves to the empty (dormant) route set here rather than through a hidden + * fallback, and each route's models and pi-ai provider are materialized once. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ @@ -110,28 +175,19 @@ export function resolveProfiles( throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } const entries = Object.entries(providers ?? {}) - const supported = new Set(getBuiltinProviders()) const resolved = new Map() for (const [provider, source] of entries) { - const legacy = source as PiAiProviderProfile & { - provider?: unknown - maxRetries?: unknown - maxRetryDelayMs?: unknown - } - if ('provider' in legacy) { - throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') - } - if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { - throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') - } + rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } + if (source.displayName !== undefined && source.displayName.length === 0) { + throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`) + } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 @@ -140,15 +196,33 @@ export function resolveProfiles( `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - const { apiKeyEnv, retryPolicy, ...rest } = source + // The route key, not the installed provider's own name: the directory has + // always shown route keys, and a catalog route must not silently rename + // itself on every configuration surface just because it gained a profile. + const displayName = source.displayName ?? provider + const models = resolveRouteModels({ + provider, + ...source.api === undefined ? {} : { api: source.api }, + ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, + ...source.models === undefined ? {} : { models: source.models }, + }) + const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, provider, + displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + piProvider: buildProvider({ + provider, + displayName, + ...source.api === undefined ? {} : { api: source.api }, + ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, + models, + }), }) } return resolved diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 91cb32a181..70b1d52b42 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,10 +1,11 @@ /** * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of - * provider routes; requests select a profile by provider and resolve the - * model dynamically from pi-ai's installed catalog. Profile facts resolve per - * request over the optional `llm-pi-ai` user-settings section and the - * optional credential seam, so a changed key, endpoint, or knob reaches the - * next request without a restart; a changed *route set* (or a route's + * provider routes; a route naming an installed pi-ai provider inherits that + * provider's endpoint, protocol, and model catalog as defaults, and a route + * pi-ai does not ship is declared outright. Profile facts resolve per request + * over the optional `llm-pi-ai` user-settings section and the optional + * credential seam, so a changed key, endpoint, model, or knob reaches the next + * request without a restart; a changed *route set* (or a route's * registration-captured retry policy) re-registers the same adapter instance * in place. * @@ -13,34 +14,48 @@ * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: + * # Catalog route: everything but the credential comes from pi-ai. * openai: * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 + * # Catalog route with the catalog narrowed and one capacity corrected. * anthropic: * apiKeyEnv: ANTHROPIC_API_KEY - * openrouter: - * apiKeyEnv: OPENROUTER_API_KEY - * baseURL: https://proxy.example.com/v1 + * models: + * - id: claude-sonnet-4-5 + * contextWindow: 200000 + * # Hand-declared route: pi-ai ships nothing under this key. + * acme-gateway: + * displayName: Acme Gateway + * apiKeyEnv: ACME_GATEWAY_API_KEY + * api: openai-completions + * baseURL: https://gateway.acme.example/v1 + * models: + * - id: acme-large + * name: Acme Large + * contextWindow: 65536 + * maxTokens: 4096 * ``` * * @module @deepseek-ai/dsh-llm-pi-ai */ import type { Context } from 'cordis' -import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import { LlmError } from '@deepseek-ai/dsh-llm' -import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' +import type { AdapterRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' +import { catalogProviderIds } from './catalog.ts' import { Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] @@ -58,6 +73,26 @@ function registrationFacts(profiles: ReadonlyMap left.provider.localeCompare(right.provider)) } +/** + * The configurable-provider directory: every installed catalog route, plus + * every route the current profiles declare. A hand-declared route has no + * catalog entry, so without this union it would have no settings address and + * configuration surfaces could neither show nor edit it. + * @param profiles - the currently resolved provider profiles. + * @returns the directory entries in catalog order, declared routes last. + */ +function directoryEntries( + profiles: ReadonlyMap, +): LlmConfigurableProvider[] { + const entries = new Map() + const declare = (provider: string, displayName: string): void => { + entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] }) + } + for (const provider of catalogProviderIds()) declare(provider, provider) + for (const [provider, profile] of profiles) declare(provider, profile.displayName) + return [...entries.values()] +} + /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config @@ -114,13 +149,18 @@ export function apply(ctx: Context, config: Config): void { const adapter = new PiAiAdapter({ profiles, resolveApiKey }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every - // pi-ai provider before any route exists. - ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({ - provider, - displayName: provider, - settingsNs: NS, - settingsPath: ['providers', provider], - }))) + // pi-ai provider before any route exists. Hand-declared routes join it as + // profiles appear, and leave with them. + let directory: (() => void) | undefined + let directoryFacts: unknown + const ensureDirectory = (): void => { + const entries = directoryEntries(profiles()) + if (deepEqualJson(entries, directoryFacts)) return + directory?.() + directory = ctx.llm.registerConfigurableProviders(entries) + directoryFacts = entries + } + ensureDirectory() // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a @@ -156,6 +196,11 @@ export function apply(ctx: Context, config: Config): void { setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, + onChange: () => { + ensureRegistrationFacts() + // The directory follows the profiles the registry accepted, so a route + // that failed to register is not advertised as configurable. + ensureDirectory() + }, }) } diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts new file mode 100644 index 0000000000..fdcacd217c --- /dev/null +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -0,0 +1,155 @@ +/** + * Construction of the pi-ai `Provider` that one configured route registers into + * the adapter's `Models` collection. + * + * Two constructions, one decision: a route the installed catalog ships, whose + * profile does not override the wire protocol, **reuses that catalog provider** + * with its models replaced — the catalog provider owns API implementations this + * package cannot reconstruct (Bedrock loads its Smithy module through a + * separate entry point), so rebuilding it from parts would silently narrow + * which providers work. Every other route — one pi-ai has never heard of, or a + * catalog route pointed at a different protocol — is built by `createProvider` + * over the protocol table below. + * + * Credentials never reach this module's storage: the harness resolves a route's + * key through `ctx.credentials` before the request enters pi-ai and hands it + * over as a stream option, which `Models` presents to `resolve()` as the + * credential key. + * + * @module dsh-llm-pi-ai/provider + */ + +import { createProvider } from '@earendil-works/pi-ai' +import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' +import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' +import { azureOpenAIResponsesApi } from '@earendil-works/pi-ai/api/azure-openai-responses.lazy' +import { bedrockConverseStreamApi } from '@earendil-works/pi-ai/api/bedrock-converse-stream.lazy' +import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' +import { googleVertexApi } from '@earendil-works/pi-ai/api/google-vertex.lazy' +import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy' +import { openAICodexResponsesApi } from '@earendil-works/pi-ai/api/openai-codex-responses.lazy' +import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' +import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' +import { piMessagesApi } from '@earendil-works/pi-ai/api/pi-messages.lazy' +import { catalogProvider } from './catalog.ts' + +/** + * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded + * implementations. The table is pi-ai's own streaming API set: each entry is + * the factory that pi-ai's matching provider factory uses, so a hand-declared + * route reaches exactly the implementation a catalog route would. + */ +const PROTOCOLS: Readonly ProviderStreams>> = { + 'anthropic-messages': anthropicMessagesApi, + 'azure-openai-responses': azureOpenAIResponsesApi, + 'bedrock-converse-stream': bedrockConverseStreamApi, + 'google-generative-ai': googleGenerativeAIApi, + 'google-vertex': googleVertexApi, + 'mistral-conversations': mistralConversationsApi, + 'openai-codex-responses': openAICodexResponsesApi, + 'openai-completions': openAICompletionsApi, + 'openai-responses': openAIResponsesApi, + 'pi-messages': piMessagesApi, +} + +/** + * Every wire protocol a configured route may name, sorted for stable + * diagnostics and configuration surfaces. + * @returns the supported protocol identifiers. + */ +export function supportedProtocols(): readonly string[] { + return Object.keys(PROTOCOLS).sort() +} + +/** + * Api-key auth for a route the harness authenticates itself. `Models` calls + * this after the adapter has already resolved the route's credential, so a + * missing key here is not this layer's failure: a named-but-unresolvable + * reference has already failed the request with `MISSING_CREDENTIAL`, and a + * route naming no credential at all is deliberately unauthenticated. Reporting + * it as configured hands the decision to the protocol, which is where the + * requirement actually lives — pi-ai's OpenAI-compatible implementation, for + * one, still insists on a key or an `Authorization` header of its own. + * @param name - display name used as the resolution's status label. + * @returns the api-key auth for a harness-authenticated route. + */ +function harnessApiKeyAuth(name: string): ApiKeyAuth { + return { + name, + resolve: ({ credential }) => Promise.resolve({ + auth: credential?.key === undefined ? {} : { apiKey: credential.key }, + source: name, + }), + } +} + +/** The resolved route facts provider construction reads. */ +export interface ProviderSpec { + /** Provider route key; also the `Models` collection key and each model's `provider`. */ + provider: string + /** Display name for selectors and status labels. */ + displayName: string + /** Wire protocol override; absent means each model keeps its catalog protocol. */ + api?: string + /** Endpoint override already applied to {@link models}; kept for provider-level display. */ + baseURL?: string + /** The route's materialized models, in configuration order. */ + models: readonly Model[] +} + +/** + * Reuse an installed catalog provider with this route's models and identity. + * Model dispatch stays with the catalog provider, so its API implementations, + * compatibility quirks, and ambient credential discovery are preserved exactly. + * Catalog-owned dynamic refresh is dropped: this route's catalog is the + * settings document, and a background refresh would contradict it. + */ +function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider { + // Provider-level `baseUrl` is display metadata: pi-ai routes every request + // through `Model.baseUrl`, which model resolution has already overridden. + const baseUrl = spec.baseURL ?? base.baseUrl + return { + id: spec.provider, + name: spec.displayName, + ...baseUrl === undefined ? {} : { baseUrl }, + auth: base.auth, + getModels: () => spec.models, + // Delegated rather than copied: the catalog provider stays the receiver, so + // an implementation holding state on itself keeps working. + stream: (model, context, options) => base.stream(model, context, options), + streamSimple: (model, context, options) => base.streamSimple(model, context, options), + } +} + +/** + * Build the pi-ai provider for one resolved route. + * @param spec - the resolved route facts. + * @returns the provider to register in the adapter's `Models` collection. + * @throws Error when the route names a wire protocol this build cannot serve. + */ +export function buildProvider(spec: ProviderSpec): Provider { + const catalog = catalogProvider(spec.provider) + // A catalog route keeping its catalog protocol reuses the catalog provider; + // an explicit protocol means the deployment is repointing the route at a + // different wire format, which only the protocol table can serve. + if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec) + + // Every model on this path carries the route's protocol: model resolution + // requires one for a route the catalog cannot default, and an explicit one + // replaces each catalog model's own. So the route has a single API. + const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api] + if (factory === undefined) { + throw new Error( + `llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;` + + ` supported protocols are ${supportedProtocols().join(', ')}`, + ) + } + return createProvider({ + id: spec.provider, + name: spec.displayName, + ...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL }, + auth: { apiKey: harnessApiKeyAuth(spec.displayName) }, + models: spec.models, + api: factory(), + }) +} diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..8481420661 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -400,12 +400,14 @@ describe('provider profile lifecycle', () => { expect(server.requests).toHaveLength(0) }) - it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { + it('validates empty, underspecified, legacy-shaped, and explicitly blank profiles', () => { // Empty and omitted dicts are the dormant zero-route posture, not errors. expect(resolveProfiles({}).size).toBe(0) expect(resolveProfiles(undefined).size).toBe(0) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) - expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // A route the installed catalog does not ship is allowed, but it has no + // defaults to fall back on: it must describe its own models. + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/resolves no models/) // The pre-release array shape and its per-profile provider field fail // loud with migration directions instead of half-working. expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts new file mode 100644 index 0000000000..d0f1725845 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { resolveProfiles } from '../src/config.ts' +import { buildProvider } from '../src/provider.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +afterEach(async () => { await closeMockServers() }) + +/** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */ +function gateway(baseURL: string, overrides: Record = {}): LlmPiAi.Config { + return { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL, + models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }], + ...overrides, + }, + }, + } +} + +async function harness(config: LlmPiAi.Config): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('hand-declared providers', () => { + it('serves a route pi-ai has never heard of from its own declaration', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + const result = await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-large', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) + + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.paths).toEqual(['/v1/chat/completions']) + expect(server.headers[0]?.authorization).toBe('Bearer gw-key') + }) + + it('lists and resolves the declared models rather than a catalog', async () => { + const server = await mockServer([]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + expect(await ctx.llm.listModels('acme-gateway')).toEqual([ + { provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large' }, + ]) + const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large') + expect(info).toMatchObject({ + provider: 'acme-gateway', + id: 'acme-large', + name: 'Acme Large', + context: { contextWindow: 65_536 }, + defaultMaxTokens: 4096, + }) + }) + + 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`)) + + expect(ctx.llm.listConfigurableProviders()).toContainEqual({ + provider: 'acme-gateway', + displayName: 'Acme Gateway', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + }) + }) + + it('rejects a model whose capacity the catalog cannot supply', () => { + const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) => + () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } }) + + expect(declare({ id: 'acme-large', maxTokens: 1 })).toThrow(/needs a contextWindow/) + expect(declare({ id: 'acme-large', contextWindow: 1 })).toThrow(/needs a maxTokens/) + expect(declare({ id: '', contextWindow: 1, maxTokens: 1 })).toThrow(/empty id/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }], + }, + })).toThrow(/more than once/) + }) + + it('rejects a declaration that names no wire protocol or endpoint', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] }, + })).toThrow(/needs an api/) + expect(() => resolveProfiles({ + 'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] }, + })).toThrow(/needs a baseURL/) + }) + + it('rejects a protocol this build cannot serve, and a route that names none', () => { + const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] } + expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' })) + .toThrow(/cannot serve; supported protocols are/) + expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/) + }) + + it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => { + const server = await mockServer([{ events: textEvents }]) + // Naming no credential is the deliberately unauthenticated posture — a + // named reference that resolved to nothing would have failed with + // MISSING_CREDENTIAL long before this point. The route resolves as + // configured and the protocol decides: pi-ai's OpenAI-compatible + // implementation wants a key or an Authorization header of its own, and + // says so instead of the harness guessing a placeholder. + const ctx = await harness({ + providers: { + 'local-llm': { + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { message: 'No API key for provider: local-llm' }, + }) + expect(server.requests).toHaveLength(0) + }) + + it('authenticates an unauthenticated route through a configured header', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + 'local-llm': { + api: 'openai-completions', + baseURL: `${server.url}/v1`, + headers: { Authorization: 'Bearer local' }, + models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.headers[0]?.authorization).toBe('Bearer local') + }) + + it('rejects a capacity that is not a positive integer', () => { + const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) => + () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } }) + + expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/) + }) + + it('names the route key when no displayName is configured', () => { + const resolved = resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }) + expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway') + expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/) + }) +}) + +describe('catalog routes with per-model configuration', () => { + it('serves the installed catalog untouched when the profile lists no models', async () => { + const server = await mockServer([]) + const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + + const listed = await ctx.llm.listModels('deepseek') + expect(listed.map(model => model.id).sort()) + .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort()) + }) + + it('overrides one catalog model field and defaults the rest from the catalog', async () => { + const server = await mockServer([]) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: server.url, + models: [{ id: catalogModel.id, contextWindow: 4096 }], + }, + }, + }) + + const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id) + // The configured field wins; name and output cap still come from the catalog. + expect(info.context).toEqual({ contextWindow: 4096 }) + expect(info.name).toBe(catalogModel.name) + expect(info.defaultMaxTokens).toBe(catalogModel.maxTokens) + // An explicit list replaces the catalog rather than adding to it. + expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id]) + }) + + it('adds a model the installed catalog does not describe to a catalog route', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: `${server.url}/v1`, + models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + // The catalog route keeps its catalog protocol, so the new model reaches + // the same endpoint shape the shipped models use. + expect(server.paths).toEqual(['/v1/chat/completions']) + }) + + it('fails an unconfigured model id before any provider request', async () => { + const server = await mockServer([]) + const ctx = await harness({ + providers: { + deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] }, + }, + }) + + await expect(assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })) + .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) + expect(server.requests).toHaveLength(0) + }) + + it('preserves catalog-only model metadata the profile cannot express', () => { + // Some catalog models carry provider-required request headers; overriding a + // capacity must not drop them, because configuration has no way to restate + // them. + const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[]) + .find(model => model.headers !== undefined) + if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers') + + const resolved = resolveProfiles({ + nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] }, + }) + const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? [] + expect(model?.headers).toEqual(headered.headers) + expect(model?.contextWindow).toBe(4096) + }) + + it('keeps each model its own endpoint when the catalog route declares none', () => { + // `opencode` ships no provider-level endpoint: the address lives on every + // catalog model, so the route resolves without any configured baseURL. + const resolved = resolveProfiles({ opencode: {} }) + const models = resolved.get('opencode')?.piProvider.getModels() ?? [] + expect(models.length).toBeGreaterThan(0) + expect(models.every(model => model.baseUrl.length > 0)).toBe(true) + expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined() + }) + + it('repoints a catalog route at another wire protocol without restating its endpoint', () => { + const resolved = resolveProfiles({ openai: { api: 'openai-completions' } }) + const models = resolved.get('openai')?.piProvider.getModels() ?? [] + // The protocol changes for the whole route; each model keeps the catalog + // endpoint it already had. + expect(models.every(model => model.api === 'openai-completions')).toBe(true) + expect(models.every(model => model.baseUrl === 'https://api.openai.com/v1')).toBe(true) + }) + + it('repoints a catalog route at another wire protocol', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + // openai's catalog models speak the Responses API; naming the protocol + // explicitly moves the whole route onto Chat Completions. + openai: { + apiKey: 'k', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }], + }, + }, + }) + + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/chat/completions']) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 3f12ef4460..86c646cba6 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -1,41 +1,74 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' const streamSimple = vi.hoisted(() => vi.fn()) -// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it -// from there, so the mock must target the same specifier. -vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, streamSimple } -}) +// A hand-declared route is built by `createProvider` over the protocol table in +// `src/provider.ts`, so the table's lazy api module is the SDK boundary this +// test can observe. A catalog route dispatches through pi-ai's own provider and +// would not see this mock. +vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({ + openAICompletionsApi: () => ({ stream: streamSimple, streamSimple }), +})) import { PiAiAdapter } from '../src/adapter.ts' import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) +/** A hand-declared OpenAI-compatible route with one fully described model. */ +function gatewayAdapter(): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles({ + 'local-gateway': { + apiKey: 'test-key', + api: 'openai-completions', + baseURL: 'http://127.0.0.1:9/v1', + models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }], + }, + }), + resolveApiKey: () => Promise.resolve('test-key'), + }) +} + +async function drain(adapter: PiAiAdapter): Promise { + const chunks: StreamChunk[] = [] + for await (const chunk of adapter.stream({ + provider: 'local-gateway', + model: 'local-model', + messages: [], + })) chunks.push(chunk) + return chunks +} + describe('pi-ai SDK retry boundary', () => { it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => { - const failure = new Error('mock SDK boundary') - streamSimple.mockReturnValue({ - async * [Symbol.asyncIterator](): AsyncGenerator { - throw failure - }, - }) - const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), - resolveApiKey: () => Promise.resolve('test-key'), - }) - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ - provider: 'openai', - model: 'gpt-4.1', - messages: [], - })) { /* drain */ } - } + streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') }) + + const chunks = await drain(gatewayAdapter()) - await expect(drain()).rejects.toBe(failure) expect(streamSimple).toHaveBeenCalledOnce() - expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 }) + expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0, apiKey: 'test-key' }) + // pi-ai reports a setup failure as a terminal in-stream error rather than + // throwing, which the converter turns into the harness error finish. + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + reason: { kind: 'error', failure: { message: 'mock SDK boundary' } }, + }) + }) + + it('dispatches a hand-declared route to the endpoint and model its configuration describes', async () => { + streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') }) + + await drain(gatewayAdapter()) + + expect(streamSimple.mock.calls[0]?.[0]).toMatchObject({ + id: 'local-model', + provider: 'local-gateway', + api: 'openai-completions', + baseUrl: 'http://127.0.0.1:9/v1', + contextWindow: 8192, + maxTokens: 1024, + }) }) }) From 4c80cab108166d47f2729c22dcb9298179194361 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 11:42:35 +0800 Subject: [PATCH 03/27] fix(llm): capture an immutable snapshot per pi-ai operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +- ...6-08-03-pi-ai-declared-provider-catalog.md | 20 +- ...8-03-pi-ai-declared-provider-catalog.zh.md | 20 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/services.md | 8 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 8 +- packages/llm/llm-pi-ai/README.zh.md | 8 +- packages/llm/llm-pi-ai/src/adapter.ts | 95 +++++---- packages/llm/llm-pi-ai/src/catalog.ts | 34 ++- packages/llm/llm-pi-ai/src/config.ts | 11 +- packages/llm/llm-pi-ai/src/index.ts | 28 ++- packages/llm/llm-pi-ai/src/provider.ts | 24 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 197 +++++++++++++++++- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 68 +++++- packages/llm/llm/tests/topology.spec.ts | 26 +++ scripts/gen-cordis-catalog.ts | 1 + 21 files changed, 476 insertions(+), 104 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 7fb32d2c29..738dc42275 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: ef695f6e4c79725400ee39a2d40ead27d6559a8d -2026-08-03-pi-ai-declared-provider-catalog.zh.md: 13cfed574f646de07228da80b3e518e31fd1f50b +2026-08-03-pi-ai-declared-provider-catalog.md: 343b54c5a09be17c0e4c31c219c01b0f1119847a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: 8bc1fceb165cf6477be973944f0091db9bbe75f3 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index ef695f6e4c..343b54c5a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -15,8 +15,17 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: - `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, `reasoning`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning-level spellings, OpenAI-compatibility quirks, and model headers ride the installed entry, because restating them in configuration could not be validated. -- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. -- `adapter.ts` owns one `createModels()` collection, re-synced when resolution produces a new profile map, and serves `listModels`, `resolveModel`, and `stream` from it. A model's configured `maxTokens` becomes the seam's `defaultMaxTokens`, so a request naming no output cap now carries the configured one. +- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. +- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. +- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. + +### Snapshots, not a shared collection + +`Models.streamSimple()` resolves its provider lazily, when the returned stream is first consumed — which is after the adapter has awaited the route's credential. A single collection mutated in place would therefore let a request that started under one configuration finish under another, or fail on a provider that no longer exists, even though `llm.prepareCall()` already froze that step's config and captured its adapter registration. A configuration change builds a *new* collection and leaves the one in use alone, so the seam's per-step freeze holds all the way down: switching models mid-reply takes effect on the next step, never inside the one in flight. + +### The directory replaces atomically + +The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving. Resolution fails loud and names the route and model at fault: a model the catalog does not describe needs an explicit `contextWindow` and `maxTokens`; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. @@ -34,14 +43,17 @@ pi-ai's `Models` carries its own credential concept — a `CredentialStore` keye - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. - **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. + +- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. +- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. - **A runtime dynamic catalog** — `fetchModels` plus `ModelsStore`, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into `settings.yaml`. That action belongs to the configuration surface and is deferred with it; `settings.yaml` stays the single source of truth for what a route serves. ## Consequences -Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration, closing the case where a request carried no output cap at all. +Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata. What it costs: `settings.yaml` grows for a declared route, because a model the catalog cannot default must state its own capacity. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. ## Testing -`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, and every resolution failure that names a route or model. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index 13cfed574f..8bc1fceb16 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -15,8 +15,17 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: - `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`、`reasoning`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。思考级别拼写、OpenAI 兼容性怪癖与模型标头沿用已安装条目,因为在配置里重述它们无法被校验。 -- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。 -- `adapter.ts` 持有一个 `createModels()` 集合,在解析产出新的 profile 映射时重新同步,并由它服务 `listModels`、`resolveModel` 与 `stream`。模型已配置的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求现在会携带已配置的那一个。 +- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 +- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 +- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 + +### 快照,而不是共享集合 + +`Models.streamSimple()` 惰性解析 provider——在返回的流首次被消费时,而那已在适配器 await 路由凭据之后。因此就地改动的单一集合,会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider,尽管 `llm.prepareCall()` 早已冻结了该步的 config 并捕获了其适配器注册。配置变化改为构造**新**集合,正在被使用的那个原封不动,于是 seam 的每步冻结得以贯通到底:回复途中切换模型在下一步生效,绝不影响在途的那一步。 + +### 目录原子替换 + +可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。 解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 @@ -34,14 +43,17 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 - **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 + +- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 +- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 - **运行时动态 catalog**——`fetchModels` 加 `ModelsStore`,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 `settings.yaml`。该动作属于配置界面,与之一并暂缓;`settings.yaml` 始终是「路由服务什么」的唯一事实源。 ## Consequences -配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在自配置流出,堵上了「请求完全不带输出上限」的情形。 +配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。 代价是:声明式路由会让 `settings.yaml` 变长,因为 catalog 无法默认的模型必须自报容量。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 ## Testing -`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通,以及每一种点名路由或模型的解析失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 44b54620d8..65ba351476 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -746,7 +746,11 @@ export interface PiAiModelProfile { name?: string /** Maximum combined request and response context in tokens. */ contextWindow?: number - /** Per-request output cap materialized when a caller omits one. */ + /** + * Maximum output tokens. Configuring one also makes it this model's + * per-request default; the value inherited from the installed catalog is the + * model's capability and never becomes a request default on its own. + */ maxTokens?: number /** Whether the model exposes reasoning; defaults to the catalog capability. */ reasoning?: boolean @@ -755,7 +759,7 @@ export interface PiAiModelProfile { Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:98`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:104`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 36446f39c3..55173f08d2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -834,9 +834,9 @@ listProviders(): LlmProviderInfo[] * entry, or a provider already declared by any registration throws * `LlmError` without registering the rest. Disposed with the fiber. * @param entries - every configurable provider this plugin owns. - * @returns the disposer that withdraws all of them. + * @returns a handle that withdraws all of them, and can atomically replace them. */ -registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void +registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle /** * List every declared configurable provider, registered or dormant. @@ -908,9 +908,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:232`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:253`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a5903d8be0..298a493730 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -421,8 +421,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', }, { - signature: 'registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void', - jsDoc: '/**\n * Declare provider routes an adapter plugin can activate through\n * configuration. Registration is all-or-nothing: an empty list, invalid\n * entry, or a provider already declared by any registration throws\n * `LlmError` without registering the rest. Disposed with the fiber.\n * @param entries - every configurable provider this plugin owns.\n * @returns the disposer that withdraws all of them.\n */', + signature: 'registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle', + jsDoc: '/**\n * Declare provider routes an adapter plugin can activate through\n * configuration. Registration is all-or-nothing: an empty list, invalid\n * entry, or a provider already declared by any registration throws\n * `LlmError` without registering the rest. Disposed with the fiber.\n * @param entries - every configurable provider this plugin owns.\n * @returns a handle that withdraws all of them, and can atomically replace them.\n */', }, { signature: 'listConfigurableProviders(): LlmConfigurableProvider[]', @@ -1889,6 +1889,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerNativeCapability', declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise;\n}', }, + { + name: 'DirectoryRegistrationHandle', + declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 8f3d239ebf..5e87c87fb4 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: e597eedeb4d6e0ebf402b5547f71c9aff370d3dd -README.zh.md: e28105f1253c138b9bb0baf5d00e0c7eba0d7b52 +README.md: dde6ce989a0fd87bf2dc60ce0d85deb1856d92d5 +README.zh.md: bb87ada7b5cf883b458c8cf9ace66bbd64fa154f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 78d32b0555..e3d8f04b9b 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -55,17 +55,19 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, and `reasoning`; pricing and input modalities have no harness consumer and ride the installed entry or are absent, while reasoning-level spellings and OpenAI-compatibility quirks have no configuration surface at all because restating them cannot be validated. -Resolution fails loud, naming the offending route and model, when a route cannot be served: a model the installed catalog does not describe needs an explicit `contextWindow` and `maxTokens`, and a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. `api` accepts the protocols in `supportedProtocols()` — pi-ai's own streaming API set — and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. +Resolution fails loud, naming the offending route and model, when a route cannot be served: a model the installed catalog does not describe needs an explicit `contextWindow` and `maxTokens`, and a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. `baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions. +`supportedProtocols()` is deliberately narrower than pi-ai's full streaming API set: it holds only the protocols a profile can *completely* describe with a key, an endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and application-default credentials, Azure needs provider environment plus an api-version, and Codex authenticates through OAuth — offering those would hand back a route that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. + ## Dynamic configuration (settings + credentials) The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. -The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the configured one. +The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. @@ -75,7 +77,7 @@ The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes ## Provider/model routing and replay -Each resolved route contributes one pi-ai `Provider` to the adapter's `createModels()` collection, and requests reach the provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. +Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index dee44a81e3..98660943b7 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -55,17 +55,19 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens` 与 `reasoning`;定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席,而思考级别的协议拼写与 OpenAI 兼容性怪癖则完全没有配置面,因为重述它们无法被校验。 -解析会失败得响亮,并点名出问题的路由与模型:已安装 catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`,catalog 未提供的路由则需要 `api`、`baseURL` 和非空的 `models` 列表。`api` 接受 `supportedProtocols()` 中的协议——即 pi-ai 自己的流式 API 集合——且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 +解析会失败得响亮,并点名出问题的路由与模型:已安装 catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`,catalog 未提供的路由则需要 `api`、`baseURL` 和非空的 `models` 列表。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 `baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy;省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。 +`supportedProtocols()` 刻意窄于 pi-ai 的完整流式 API 集合:它只保留 profile 能用密钥、端点与标头**完整描述**的那些协议。Bedrock 要用 AWS 凭据与 region 做 SigV4 签名,Vertex 需要 project、location 与应用默认凭据,Azure 需要提供方环境外加 api-version,Codex 走 OAuth——提供它们只会交回一个无法完成认证的路由。catalog 路由仍可经自己的 provider 抵达这些协议;被拒绝的只有显式覆盖。 + ## 动态配置(settings + credentials) 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 -适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带已配置的那一个。 +适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 `reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 @@ -75,7 +77,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 ## 提供方/模型路由与回放 -每条已解析路由都会向适配器的 `createModels()` 集合贡献一个 pi-ai `Provider`,请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 +每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己失败得响亮的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index f43bef2649..3a70d0f4af 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -1,10 +1,17 @@ /** * Generic pi-ai-backed implementation of the Harness LLM seam. * - * The adapter owns one pi-ai `Models` collection and keeps it in step with the - * resolved profiles: each route contributes the `Provider` its resolution built, - * so model lookup, protocol dispatch, and request auth all reach pi-ai through - * its supported runtime rather than the deprecated global compatibility entry. + * Each resolution produces one **immutable** snapshot — the profiles plus a + * `Models` collection holding the `Provider` each route built — and an + * operation captures a whole snapshot before its first `await`. A + * configuration change builds a *new* collection rather than mutating the one + * in use, because `Models.streamSimple()` is lazy: it resolves the provider + * when the stream is first consumed, which is after the credential await, so a + * mutated collection would let a request that started under one configuration + * finish under another — or fail with a provider that no longer exists. This is + * what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the + * way down: switching models mid-reply takes effect on the next step, never + * inside the one in flight. * * Credentials stay outside that collection. The harness resolves a route's key * through its own seam and passes it as the request's `apiKey` option, which @@ -18,6 +25,7 @@ import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, Model, + Models, ModelThinkingLevel, MutableModels, SimpleStreamOptions, @@ -42,6 +50,14 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' +/** One resolution's frozen view: the profiles and the collection built from them. */ +interface PiAiSnapshot { + /** The resolved profiles this collection was built from, used as its identity. */ + profiles: ReadonlyMap + /** Providers for exactly those profiles; never mutated once published. */ + models: Models +} + /** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { /** Current validated profiles by provider route; called once per operation. */ @@ -107,41 +123,40 @@ function requestHeaders(headers: Readonly> | undefined): * restart; model descriptors come from the collection those profiles built. */ export class PiAiAdapter extends LlmAdapter { - private readonly models: MutableModels = createModels() - private registered: ReadonlyMap | undefined + private snapshot: PiAiSnapshot | undefined constructor(private readonly config: PiAiAdapterOptions) { super() } /** - * The `Models` collection for the current profiles. Resolution memoizes its - * result, so an unchanged configuration is recognized by identity and the - * collection is rebuilt only when the route set or any profile actually - * changes. + * The snapshot for the current profiles. Resolution memoizes its result, so + * an unchanged configuration is recognized by identity; a changed one gets a + * brand-new collection, leaving any snapshot an operation already captured + * untouched for as long as that operation holds it. */ - private collection(): MutableModels { + private current(): PiAiSnapshot { const profiles = this.config.profiles() - if (profiles === this.registered) return this.models - this.models.clearProviders() - for (const profile of profiles.values()) this.models.setProvider(profile.piProvider) - this.registered = profiles - return this.models + if (this.snapshot?.profiles === profiles) return this.snapshot + const models: MutableModels = createModels() + for (const profile of profiles.values()) models.setProvider(profile.piProvider) + this.snapshot = { profiles, models } + return this.snapshot } - /** The profile for one route, or the seam's own not-owned failure. */ - private profileOf(provider: string): ResolvedPiAiProviderProfile { - const profile = this.config.profiles().get(provider) + /** The profile for one route within one snapshot, or the not-owned failure. */ + private profileOf(snapshot: PiAiSnapshot, provider: string): ResolvedPiAiProviderProfile { + const profile = snapshot.profiles.get(provider) if (profile === undefined) { throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER') } return profile } - /** The configured descriptor for one exact route/model pair. */ - private modelOf(provider: string, model: string): Model { - this.profileOf(provider) - const resolved = this.collection().getModel(provider, model) + /** The configured descriptor for one exact route/model pair within one snapshot. */ + private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model { + this.profileOf(snapshot, provider) + const resolved = snapshot.models.getModel(provider, model) if (resolved === undefined) { throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL') } @@ -149,13 +164,14 @@ export class PiAiAdapter extends LlmAdapter { } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.config.profiles().get(provider)?.retryPolicy + return this.current().profiles.get(provider)?.retryPolicy } override listModels(provider: string): Promise { return Promise.resolve().then(() => { - this.profileOf(provider) - return this.collection().getModels(provider).map(model => ({ + const snapshot = this.current() + this.profileOf(snapshot, provider) + return snapshot.models.getModels(provider).map(model => ({ provider, id: model.id, name: model.name, @@ -169,16 +185,20 @@ export class PiAiAdapter extends LlmAdapter { _signal?: AbortSignal, ): Promise { return Promise.resolve().then(() => { - const profile = this.profileOf(provider) - const resolvedModel = this.modelOf(provider, model) + const snapshot = this.current() + const profile = this.profileOf(snapshot, provider) + const resolvedModel = this.modelOf(snapshot, provider, model) const levels = getSupportedThinkingLevels(resolvedModel) const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + // Only a cap the deployment configured is a request default; the + // catalog's `maxTokens` sizes the model and stops there. + const configuredMaxTokens = profile.configuredMaxTokens.get(model) return { provider, id: model, name: resolvedModel.name, context: { contextWindow: resolvedModel.contextWindow }, - defaultMaxTokens: resolvedModel.maxTokens, + ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, reasoning: { efforts: levels.map(level => ({ id: ReasoningEffortId(level), @@ -196,13 +216,14 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - // One resolution per stream call: the profile snapshot, the model - // descriptor, and the credential freeze here and hold for this whole - // request, so an in-flight stream never observes a configuration change and - // the next call re-resolves. - const profile = this.profileOf(options.provider) - const collection = this.collection() - const model = this.modelOf(options.provider, options.model) + // One capture per stream call, taken before any await: the profile, the + // model descriptor, and the collection all come from the same immutable + // snapshot, and the credential freezes with them. A configuration change + // mid-request builds a separate snapshot, so this request finishes under + // the one it started with and the next call picks up the new one. + const snapshot = this.current() + const profile = this.profileOf(snapshot, options.provider) + const model = this.modelOf(snapshot, options.provider, options.model) const reasoning = resolveReasoningLevel( model, options.reasoningEffort ?? profile.reasoning, @@ -217,7 +238,7 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = collection.streamSimple(model, toPiContext(options), { + const events = snapshot.models.streamSimple(model, toPiContext(options), { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 41e5527b39..a68c5fd9e8 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -79,7 +79,11 @@ export interface PiAiModelProfile { name?: string /** Maximum combined request and response context in tokens. */ contextWindow?: number - /** Per-request output cap materialized when a caller omits one. */ + /** + * Maximum output tokens. Configuring one also makes it this model's + * per-request default; the value inherited from the installed catalog is the + * model's capability and never becomes a request default on its own. + */ maxTokens?: number /** Whether the model exposes reasoning; defaults to the catalog capability. */ reasoning?: boolean @@ -116,15 +120,32 @@ function sharedCatalogApi(defaults: ReadonlyMap>): string | u return apis.size === 1 ? [...apis][0] : undefined } +/** One route's materialized catalog, plus the request caps its profile chose. */ +export interface RouteCatalog { + /** The materialized models in configuration order. */ + models: readonly Model[] + /** + * Per-request output caps this profile explicitly configured, by model id. + * + * Separate from `Model.maxTokens` because the two answer different + * questions: pi-ai requires `maxTokens` as the model's output *capability*, + * while the harness seam's `defaultMaxTokens` is a cap the deployment chose + * to send on requests that name none. Materializing a catalog capability as + * a request default would start capping every request at a number nobody + * picked, so only an explicit configuration lands here. + */ + configuredMaxTokens: ReadonlyMap +} + /** * Materialize one route's catalog by merging the installed catalog defaults * under the configured entries. A route with no configured `models` serves the * installed catalog unchanged, which is what keeps an existing * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched. * @param request - the route-level catalog facts. - * @returns the materialized models in configuration order. + * @returns the materialized models and the explicitly configured request caps. */ -export function resolveRouteModels(request: RouteCatalogRequest): readonly Model[] { +export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { const { provider } = request const defaults = catalogModels(provider) const providerBaseUrl = catalogProvider(provider)?.baseUrl @@ -141,7 +162,8 @@ export function resolveRouteModels(request: RouteCatalogRequest): readonly Model } const routeApi = sharedCatalogApi(defaults) const seen = new Set() - return entries.map((entry) => { + const configuredMaxTokens = new Map() + const models = entries.map((entry) => { if (entry.id.length === 0) invalid(provider, 'has a model with an empty id') if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`) seen.add(entry.id) @@ -171,6 +193,9 @@ export function resolveRouteModels(request: RouteCatalogRequest): readonly Model if (!Number.isInteger(maxTokens) || maxTokens <= 0) { invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`) } + // Only a value the profile named is a deployment choice; the catalog's is + // the model's capability and stays out of request defaults. + if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens) return { id: entry.id, name: entry.name ?? base?.name ?? entry.id, @@ -190,4 +215,5 @@ export function resolveRouteModels(request: RouteCatalogRequest): readonly Model ...base?.headers === undefined ? {} : { headers: base.headers }, } }) + return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index a1199ad471..01a63efb4a 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -92,6 +92,12 @@ export interface ResolvedPiAiProviderProfile * serving requests. */ piProvider: Provider + /** + * Per-request output caps this profile explicitly configured, by model id. + * The seam materializes one only into a request that names no cap of its + * own, so a catalog capability must not appear here. + */ + configuredMaxTokens: ReadonlyMap } /** Plugin configuration: the provider routes this instance owns. */ @@ -200,7 +206,7 @@ export function resolveProfiles( // always shown route keys, and a catalog route must not silently rename // itself on every configuration surface just because it gained a profile. const displayName = source.displayName ?? provider - const models = resolveRouteModels({ + const catalog = resolveRouteModels({ provider, ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, @@ -216,12 +222,13 @@ export function resolveProfiles( retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + configuredMaxTokens: catalog.configuredMaxTokens, piProvider: buildProvider({ provider, displayName, ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, - models, + models: catalog.models, }), }) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 70b1d52b42..bc0e77658c 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -44,7 +44,7 @@ import type { Context } from 'cordis' import { LlmError } from '@deepseek-ai/dsh-llm' -import type { AdapterRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' +import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { catalogProviderIds } from './catalog.ts' @@ -151,13 +151,21 @@ export function apply(ctx: Context, config: Config): void { // mounts — dormant or not — so configuration surfaces can offer every // pi-ai provider before any route exists. Hand-declared routes join it as // profiles appear, and leave with them. - let directory: (() => void) | undefined + let directory: DirectoryRegistrationHandle | undefined let directoryFacts: unknown const ensureDirectory = (): void => { const entries = directoryEntries(profiles()) if (deepEqualJson(entries, directoryFacts)) return - directory?.() - directory = ctx.llm.registerConfigurableProviders(entries) + // Atomic replace, never dispose-then-register: a route another adapter + // family already declares (a profile keyed `deepseek-official`) would + // otherwise leave this plugin's whole directory withdrawn and the Models + // page empty. The candidate set is validated first, so a collision keeps + // the previous entries serving and only costs a diagnostic. + if (directory === undefined) { + directory = ctx.llm.registerConfigurableProviders(entries) + } else { + directory.replace(entries) + } directoryFacts = entries } ensureDirectory() @@ -199,8 +207,16 @@ export function apply(ctx: Context, config: Config): void { onChange: () => { ensureRegistrationFacts() // The directory follows the profiles the registry accepted, so a route - // that failed to register is not advertised as configurable. - ensureDirectory() + // that failed to register is not advertised as configurable. A refused + // directory swap is contained here for the same reason the registry's + // is: the previous entries keep serving, and `directoryFacts` stays put + // so returning to a working configuration re-applies. + try { + ensureDirectory() + } catch (error) { + ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update') + ctx.logger.error(error) + } }, }) } diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index fdcacd217c..07ec533919 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -22,12 +22,8 @@ import { createProvider } from '@earendil-works/pi-ai' import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' -import { azureOpenAIResponsesApi } from '@earendil-works/pi-ai/api/azure-openai-responses.lazy' -import { bedrockConverseStreamApi } from '@earendil-works/pi-ai/api/bedrock-converse-stream.lazy' import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' -import { googleVertexApi } from '@earendil-works/pi-ai/api/google-vertex.lazy' import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy' -import { openAICodexResponsesApi } from '@earendil-works/pi-ai/api/openai-codex-responses.lazy' import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' import { piMessagesApi } from '@earendil-works/pi-ai/api/pi-messages.lazy' @@ -35,18 +31,24 @@ import { catalogProvider } from './catalog.ts' /** * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded - * implementations. The table is pi-ai's own streaming API set: each entry is - * the factory that pi-ai's matching provider factory uses, so a hand-declared - * route reaches exactly the implementation a catalog route would. + * implementations. Each entry is the factory that pi-ai's matching provider + * factory uses, so a hand-declared route reaches exactly the implementation a + * catalog route would. + * + * The table is deliberately narrower than pi-ai's full streaming API set: it + * holds only the protocols a profile can *completely* describe with a key, an + * endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a + * region, Vertex needs a project, a location, and application-default + * credentials, Azure needs provider environment plus an api-version, and + * Codex authenticates through OAuth — none of which this configuration shape + * can express, so offering them would hand back a provider that cannot + * authenticate. Catalog routes still reach those protocols through their own + * provider; only an explicit override is refused. */ const PROTOCOLS: Readonly ProviderStreams>> = { 'anthropic-messages': anthropicMessagesApi, - 'azure-openai-responses': azureOpenAIResponsesApi, - 'bedrock-converse-stream': bedrockConverseStreamApi, 'google-generative-ai': googleGenerativeAIApi, - 'google-vertex': googleVertexApi, 'mistral-conversations': mistralConversationsApi, - 'openai-codex-responses': openAICodexResponsesApi, 'openai-completions': openAICompletionsApi, 'openai-responses': openAIResponsesApi, 'pi-messages': piMessagesApi, diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index d0f1725845..b2684c1997 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -1,14 +1,43 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { resolveProfiles } from '../src/config.ts' -import { buildProvider } from '../src/provider.ts' +import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -afterEach(async () => { await closeMockServers() }) +const homes: string[] = [] + +afterEach(async () => { + await closeMockServers() + await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** A throwaway $DSH_HOME with an empty settings document. */ +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-')) + homes.push(dir) + await writeFile(join(dir, 'settings.yaml'), '') + return dir +} + +/** The dormant composition plus a real settings service, as the product mounts it. */ +async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} /** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */ function gateway(baseURL: string, overrides: Record = {}): LlmPiAi.Config { @@ -107,6 +136,19 @@ describe('hand-declared providers', () => { })).toThrow(/needs a baseURL/) }) + it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])( + 'refuses %s, whose authentication a profile cannot express', + (api) => { + // These need SigV4 credentials and a region, a project plus ADC, provider + // environment and an api-version, or OAuth — none of which a key, an + // endpoint, and headers can carry, so a route naming one would be built + // unable to authenticate. + expect(supportedProtocols()).not.toContain(api) + expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [] })) + .toThrow(/cannot serve; supported protocols are/) + }, + ) + it('rejects a protocol this build cannot serve, and a route that names none', () => { const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] } expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' })) @@ -206,14 +248,35 @@ describe('catalog routes with per-model configuration', () => { }) const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id) - // The configured field wins; name and output cap still come from the catalog. + // The configured field wins and the name still comes from the catalog. The + // catalog's own output cap is the model's capability, not a cap anyone + // chose, so it must not arrive as the request default. expect(info.context).toEqual({ contextWindow: 4096 }) expect(info.name).toBe(catalogModel.name) - expect(info.defaultMaxTokens).toBe(catalogModel.maxTokens) + expect(info.defaultMaxTokens).toBeUndefined() // An explicit list replaces the catalog rather than adding to it. expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id]) }) + it('materializes a request default only from a configured output cap', async () => { + const server = await mockServer([]) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: server.url, + models: [{ id: catalogModel.id, maxTokens: 4096 }], + }, + }, + }) + + // Configuring the cap is the deployment choosing one, so it becomes the + // default the seam materializes into requests that name none. + expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096) + }) + it('adds a model the installed catalog does not describe to a catalog route', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness({ @@ -262,6 +325,23 @@ describe('catalog routes with per-model configuration', () => { expect(model?.contextWindow).toBe(4096) }) + it('delegates both stream methods back to the reused catalog provider', async () => { + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + const built = resolved.get('deepseek')?.piProvider + if (built === undefined) throw new Error('the deepseek route built no provider') + const [model] = built.getModels() + if (model === undefined) throw new Error('the deepseek route resolved no models') + const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] } + + // `stream` is interface-required and unused by the harness adapter, which + // only calls `streamSimple`; both must still reach the catalog provider. + for await (const _event of built.stream(model, context, { apiKey: 'k' })) { /* drain */ } + for await (const _event of built.streamSimple(model, context, { apiKey: 'k' })) { /* drain */ } + + expect(server.paths).toEqual(['/v1/chat/completions', '/v1/chat/completions']) + }) + it('keeps each model its own endpoint when the catalog route declares none', () => { // `opencode` ships no provider-level endpoint: the address lives on every // catalog model, so the route resolves without any configured baseURL. @@ -300,3 +380,112 @@ describe('catalog routes with per-model configuration', () => { expect(server.paths).toEqual(['/v1/chat/completions']) }) }) + +describe('resolution snapshots', () => { + it('finishes an in-flight request under the configuration it started with', async () => { + const server = await mockServer([{ events: textEvents }]) + let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + let release: () => void = () => {} + const held = new Promise((resolve) => { release = resolve }) + const adapter = new PiAiAdapter({ + profiles: () => current, + // Credential resolution is the real await inside a stream call, and the + // window a configuration change has to land in. + resolveApiKey: async () => { await held; return 'k' }, + }) + + const chunks: StreamChunk[] = [] + const inFlight = (async () => { + for await (const chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [], + })) chunks.push(chunk) + })() + + // The route set changes while the request waits, and something else reads + // the adapter meanwhile, which is what would rebuild a shared collection. + current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0) + release() + await inFlight + + // The in-flight request keeps its own snapshot: it reaches the endpoint it + // resolved against instead of failing on a provider that no longer exists. + expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'stop' } }) + expect(server.paths).toEqual(['/v1/chat/completions']) + }) + + it('serves the next request from the new configuration', async () => { + const first = await mockServer([{ events: textEvents }]) + const second = await mockServer([{ events: textEvents }]) + let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } }) + const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + })) { /* drain */ } + } + + await drain() + current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } }) + await drain() + + expect(first.paths).toHaveLength(1) + expect(second.paths).toHaveLength(1) + }) +}) + +describe('configurable-provider directory', () => { + it('keeps the previous directory when a route collides with another adapter family', async () => { + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + // Another adapter family owns this route id, exactly as llm-deepseek does. + ctx.llm.registerConfigurableProviders([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + ]) + const before = ctx.llm.listConfigurableProviders().length + expect(before).toBeGreaterThan(30) + + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'deepseek-official': { + apiKey: 'k', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }, + }) + + // The refused swap costs a diagnostic, not the directory: every entry the + // page needs is still declared. + expect(ctx.llm.listConfigurableProviders()).toHaveLength(before) + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'deepseek-official')?.settingsNs) + .toBe('llm-deepseek') + }) + + it('replaces its entries atomically as declared routes come and go', async () => { + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + const catalogOnly = ctx.llm.listConfigurableProviders().length + + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + apiKey: 'k', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }, + }) + expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly + 1) + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName) + .toBe('Acme Gateway') + + await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {}) + expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) + }) +}) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index a561022e43..473187c895 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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: 21f428fb22c9a59a67d86f446ea866c1629b964a -README.zh.md: 9bd26993bc63b39de3d3a8039fea6b046c875504 +README.md: e09ec685ed0ab1e2492749237c277a874eb3b246 +README.zh.md: ca98e875a90eb16e32bc405d77cd5b2b56644180 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 21f428fb22..e09ec685ed 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -12,7 +12,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[]): () => void` 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. +- `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.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 9bd26993bc..ca98e875a9 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -12,7 +12,7 @@ - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 -- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。 +- `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.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8b3c839787..4c1e8e94d5 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -225,6 +225,27 @@ export interface AdapterRegistrationHandle { replace(providers: string[]): void } +/** + * A live configurable-provider registration, disposable and atomically + * replaceable — the directory counterpart of {@link AdapterRegistrationHandle}. + */ +export interface DirectoryRegistrationHandle { + /** Withdraw every entry this registration currently holds. */ + (): void + /** + * Replace this registration's entries with `entries`. The candidate set is + * validated in full first — an entry another registration already declares, + * a duplicate within the set, or invalid metadata throws and leaves the + * current entries untouched — and the swap is one synchronous section, so no + * reader observes a gap. An empty array is legal here, unlike an empty + * initial registration. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been disposed. + */ + replace(entries: readonly LlmConfigurableProvider[]): void +} + /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -370,34 +391,61 @@ export class LlmService extends Service { * entry, or a provider already declared by any registration throws * `LlmError` without registering the rest. Disposed with the fiber. * @param entries - every configurable provider this plugin owns. - * @returns the disposer that withdraws all of them. + * @returns a handle that withdraws all of them, and can atomically replace them. */ - registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void { - const dispose = this.ctx.effect(function* (this: LlmService) { - if (entries.length === 0) { - throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY') - } + registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle { + let held: LlmConfigurableProvider[] = [] + let disposed = false + /** + * Validate a candidate set in full against everything this registration + * does not already hold, then publish it. Nothing is written until the + * whole set passes, so a refused candidate leaves the current entries in + * place — the property that makes `replace` a swap rather than a + * delete-then-add that can strand the directory empty. + */ + const commit = (candidates: readonly LlmConfigurableProvider[]): void => { const detached: LlmConfigurableProvider[] = [] - for (const entry of entries) { + const own = new Set(held.map(entry => entry.provider)) + for (const entry of candidates) { if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) { throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY') } if (entry.settingsPath.some(segment => segment.length === 0)) { throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY') } - if (this.directory.has(entry.provider) || detached.some(seen => seen.provider === entry.provider)) { + if ((this.directory.has(entry.provider) && !own.has(entry.provider)) + || detached.some(seen => seen.provider === entry.provider)) { throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY') } detached.push({ ...entry, settingsPath: [...entry.settingsPath] }) } + for (const entry of held) this.directory.delete(entry.provider) for (const entry of detached) this.directory.set(entry.provider, entry) + held = detached this.emitAdaptersUpdated() + } + + const dispose = this.ctx.effect(function* (this: LlmService) { + if (entries.length === 0) { + throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY') + } + commit(entries) yield () => { - for (const entry of detached) this.directory.delete(entry.provider) + disposed = true + for (const entry of held) this.directory.delete(entry.provider) + held = [] this.emitAdaptersUpdated() } }.bind(this), 'llm.registerConfigurableProviders()') - return () => void dispose() + + const handle = ((): void => void dispose()) as DirectoryRegistrationHandle + handle.replace = (next: readonly LlmConfigurableProvider[]): void => { + if (disposed) { + throw new LlmError('this configurable-provider registration was disposed', 'REGISTRATION_DISPOSED') + } + commit(next) + } + return handle } /** diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index f07b33af7d..3e54db480f 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -170,6 +170,32 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toEqual([]) }) + it('replaces its entries atomically, keeping the old set when a candidate collides', async () => { + const ctx = await setup() + const handle = ctx.llm.registerConfigurableProviders([entry(), entry({ provider: 'second' })]) + ctx.llm.registerConfigurableProviders([entry({ provider: 'owned-elsewhere' })]) + + // A candidate another registration already declares refuses the whole swap. + expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]); }).toThrow(/already declared/) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort()) + .toEqual(['owned-elsewhere', 'second', entry().provider].sort()) + + // Its own entries are not "already declared" against itself, so a swap that + // keeps one and drops another lands whole. + handle.replace([entry({ displayName: 'Renamed' })]) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort()) + .toEqual(['owned-elsewhere', entry().provider].sort()) + expect(ctx.llm.listConfigurableProviders().find(view => view.provider === entry().provider)?.displayName) + .toBe('Renamed') + + // An empty replace is legal, unlike an empty initial registration. + handle.replace([]) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere']) + + handle() + expect(() =>{ handle.replace([entry()]); }).toThrow(/was disposed/) + }) + it('rejects duplicates within one registration and across registrations', async () => { const ctx = await setup() expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4dd381af69..30e822910e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -34,6 +34,7 @@ export const LINK_MAP: Readonly> = { HookContext: 'core.md', SettleReason: 'core.md', AdapterRegistrationHandle: 'core.md', + DirectoryRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', From f376ee23d1f9310892dd4796e3cba693e825dc4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 13:32:56 +0800 Subject: [PATCH 04/27] fix(llm): size unknown models and refuse a section that cannot be served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak. --- docs/config-catalog.md | 21 +++++-- docs/cordis-catalog/events.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/settings.i18n.yaml | 4 +- docs/core-data-structures/settings.md | 21 ++++++- docs/core-data-structures/settings.zh.md | 21 ++++++- docs/event-producer-consumer.md | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 24 ++++++-- packages/llm/llm-pi-ai/README.zh.md | 24 ++++++-- packages/llm/llm-pi-ai/src/adapter.ts | 43 ++++++++++---- packages/llm/llm-pi-ai/src/catalog.ts | 32 +++++----- packages/llm/llm-pi-ai/src/config.ts | 39 +++++++++++- packages/llm/llm-pi-ai/src/index.ts | 36 ++++++----- packages/llm/llm-pi-ai/src/provider.ts | 33 +++++------ packages/llm/llm-pi-ai/tests/adapter.spec.ts | 11 ++-- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 59 +++++++++++++++++-- .../llm-pi-ai/tests/dynamic-config.spec.ts | 11 ++-- packages/llm/llm/tests/topology.spec.ts | 4 +- packages/settings/settings/src/index.ts | 52 ++++++++++++++-- .../settings/settings/tests/settings.spec.ts | 25 ++++++++ 22 files changed, 368 insertions(+), 108 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 65ba351476..b6d9d12189 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -718,6 +718,18 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Context capacity for a model this route lists that neither the entry nor + * the installed catalog sizes (default 262,144). A guess by construction, so + * a deployment whose gateway serves smaller models corrects it here. + */ + defaultContextWindow?: number + /** + * Output capability for a model this route lists that neither the entry nor + * the installed catalog sizes (default 32,768). This sizes the model; it + * never becomes a per-request cap on its own. + */ + defaultMaxTokens?: number /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ @@ -748,18 +760,17 @@ export interface PiAiModelProfile { contextWindow?: number /** * Maximum output tokens. Configuring one also makes it this model's - * per-request default; the value inherited from the installed catalog is the - * model's capability and never becomes a request default on its own. + * per-request default; a value inherited from the installed catalog, or the + * route's fallback, is the model's capability and never becomes a request + * default on its own. */ maxTokens?: number - /** Whether the model exposes reasoning; defaults to the catalog capability. */ - reasoning?: boolean } ``` Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:104`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index b1578717fd..423f5bc9cb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -726,7 +726,7 @@ One registered namespace's RAW user section changed, whether or not the resolved Types: [SettingsNamespace](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:150`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:167`](../../packages/settings/settings/src/index.ts) ### `settings/updated` — emit @@ -753,7 +753,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:137`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:154`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 55173f08d2..d2b679657f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1813,7 +1813,7 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsPathOp](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:365`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:384`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index 50c20a0aab..bc8a9893b8 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.i18n.yaml @@ -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 docs/core-data-structures/settings.md -settings.md: 1cabfae5d8dc72a9cd79341d250ee79820693872 -settings.zh.md: d63a1384646fa38199e7d65e9f0504f0440597be +settings.md: 08f99b5e65ac4a57cdfff3323c0d9148d379bed8 +settings.zh.md: 08903810b44c293944950a8f5ae2710f45678d60 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index 1cabfae5d8..08f99b5e65 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## Registration -Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer and the owner's effect timing. +Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express. ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -26,9 +26,28 @@ interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } ``` +`validate` runs after the schema admits a value, so it sees defaults and the composition base exactly as the owner will. `dsh-llm-pi-ai` uses it to refuse a provider profile it could not serve at the write that produced it, rather than storing one that would disable every route in its namespace. + `applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change. ```ts type-equiv diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index d63a138464..08903810b4 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -17,7 +17,7 @@ type SettingsNamespace = Branded<'SettingsNamespace'> ## 注册 -注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层与 owner 的生效时机。 +注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose 该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。 ```ts type-equiv /** Registration options beyond the namespace schema. */ @@ -26,9 +26,28 @@ interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } ``` +`validate` 在 schema 接纳该值之后运行,因此它看到的默认值与组合 base 与 owner 将看到的完全一致。`dsh-llm-pi-ai` 用它在写入处拒绝自己无法服务的提供方 profile,而不是先存下来、再让该 namespace 下每条路由失效。 + `applies` 是 UI 提示而非机制:`restart` 的 owner 只是从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。 ```ts type-equiv diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cd53931df3..7fe303e0ef 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,8 +38,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:167`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:154`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 298a493730..7feea98742 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2639,7 +2639,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsRegisterOptions', - declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, { name: 'SettingsScope', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 5e87c87fb4..89cecdfdec 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: dde6ce989a0fd87bf2dc60ce0d85deb1856d92d5 -README.zh.md: bb87ada7b5cf883b458c8cf9ace66bbd64fa154f +README.md: 884eadbde73f17ffd50994e09c975cca561d81ed +README.zh.md: af8a620eeccb2b971c4550bdcd7c8af93d5d4f90 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e3d8f04b9b..a8d425a4bd 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -53,9 +53,11 @@ The dict shape makes duplicate routes unrepresentable, and the pre-release array ## Catalog resolution -A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, and `reasoning`; pricing and input modalities have no harness consumer and ride the installed entry or are absent, while reasoning-level spellings and OpenAI-compatibility quirks have no configuration surface at all because restating them cannot be validated. +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent. -Resolution fails loud, naming the offending route and model, when a route cannot be served: a model the installed catalog does not describe needs an explicit `contextWindow` and `maxTokens`, and a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. +A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. + +Resolution still fails loud, naming the offending route and model, when a route cannot be served at all: a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list of uniquely-identified models. That resolution runs inside the section schema, so an unserviceable profile is refused **where it is written** — `settings.mutate` answers `settings-rejected` naming the route and model — rather than being stored and then quietly disabling every route in the namespace. The settings seam keeps a namespace's last good value for an already-stored section that fails, so this cannot strand a deployment. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. `baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions. @@ -69,12 +71,24 @@ Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `ap The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. -The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. + +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. +## Endpoint interrogation + +The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves. + +A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. + +Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. + +Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. + ## Provider/model routing and replay Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. @@ -137,7 +151,7 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). -- **Model discovery is configuration, not a provider query** — the route's catalog is whatever `settings.yaml` says; nothing fetches a provider's `/models` endpoint, so a model list is only as current as its last edit. A one-shot discovery action that offers a provider's live list for the user to adopt belongs to the configuration surface and is deferred with it. +- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Endpoint interrogation is an explicit action a configuration surface takes over a draft; nothing re-runs it, and adopting its result is a settings write like any other. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. - **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 98660943b7..00a20c5e60 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -53,9 +53,11 @@ ## Catalog 解析 -profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens` 与 `reasoning`;定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席,而思考级别的协议拼写与 OpenAI 兼容性怪癖则完全没有配置面,因为重述它们无法被校验。 +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 -解析会失败得响亮,并点名出问题的路由与模型:已安装 catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`,catalog 未提供的路由则需要 `api`、`baseURL` 和非空的 `models` 列表。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 +条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。 + +路由完全无法服务时解析仍会失败得响亮,并点名出问题的路由与模型:catalog 未提供的路由需要 `api`、`baseURL`,以及一个由唯一标识的模型组成的非空 `models` 列表。该解析在分节 schema 内部运行,因此无法服务的 profile 会在**写入之处**被拒绝——`settings.mutate` 以 `settings-rejected` 点名路由与模型——而不是先存下来、再悄悄让该 namespace 下每条路由失效。对于已经存下的、在此失败的分节,settings seam 会保留该 namespace 上一份可用值,因此这不会把部署卡死。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 `baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy;省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。 @@ -69,12 +71,24 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 -`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 + +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 +## 端点询问 + +插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。 + +点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 + +询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 + +多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 + ## 提供方/模型路由与回放 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 @@ -137,7 +151,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **模型发现属于配置,不是提供方查询**:路由的 catalog 就是 `settings.yaml` 所写的内容;没有任何环节会去拉取提供方的 `/models` 端点,因此模型列表的新鲜度只到最近一次编辑为止。把提供方实时列表呈给用户采纳的一次性发现动作属于配置界面,与之一并暂缓。 +- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。端点询问是配置界面针对草稿主动发起的动作;没有任何环节会重跑它,采纳其结果与任何其他 settings 写入无异。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3a70d0f4af..2cdb7e6657 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -107,6 +107,38 @@ function resolveReasoningLevel( ) } +/** + * Selectable reasoning efforts for one model, or nothing at all. + * + * A model the installed catalog does not describe carries no reasoning + * metadata, and pi-ai reports that as the single level `off`. Passing that + * through would offer a control that cannot do what it says: `off` is + * translated to *omitting* the reasoning option, which for such a model is + * byte-for-byte the same request as naming no effort — so a provider whose own + * default is to think would keep thinking with `off` selected. Omitting + * `reasoning` entirely is the seam's way of saying the capability is + * unavailable, which leaves the surface offering only the provider's default. + * @param model - the resolved model descriptor. + * @param defaultLevel - the profile's configured effort, already validated. + * @returns the `reasoning` field, or an empty object when none can be offered. + */ +function reasoningInfo( + model: Model, + defaultLevel: ModelThinkingLevel | undefined, +): Pick | Record { + if (!model.reasoning) return {} + const levels = getSupportedThinkingLevels(model) + return { + reasoning: { + efforts: levels.map(level => ({ + id: ReasoningEffortId(level), + name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, + })), + ...defaultLevel === undefined ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) }, + }, + } +} + /** Merge deployment headers while removing case-insensitive attribution collisions. */ function requestHeaders(headers: Readonly> | undefined): Record { const attribution = attributionHeaders() @@ -188,7 +220,6 @@ export class PiAiAdapter extends LlmAdapter { const snapshot = this.current() const profile = this.profileOf(snapshot, provider) const resolvedModel = this.modelOf(snapshot, provider, model) - const levels = getSupportedThinkingLevels(resolvedModel) const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) // Only a cap the deployment configured is a request default; the // catalog's `maxTokens` sizes the model and stops there. @@ -199,15 +230,7 @@ export class PiAiAdapter extends LlmAdapter { name: resolvedModel.name, context: { contextWindow: resolvedModel.contextWindow }, ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, - reasoning: { - efforts: levels.map(level => ({ - id: ReasoningEffortId(level), - name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, - })), - ...defaultLevel === undefined - ? {} - : { defaultEffort: ReasoningEffortId(defaultLevel) }, - }, + ...reasoningInfo(resolvedModel, defaultLevel), } }) } diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index a68c5fd9e8..2ac66b5a5e 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -81,12 +81,11 @@ export interface PiAiModelProfile { contextWindow?: number /** * Maximum output tokens. Configuring one also makes it this model's - * per-request default; the value inherited from the installed catalog is the - * model's capability and never becomes a request default on its own. + * per-request default; a value inherited from the installed catalog, or the + * route's fallback, is the model's capability and never becomes a request + * default on its own. */ maxTokens?: number - /** Whether the model exposes reasoning; defaults to the catalog capability. */ - reasoning?: boolean } /** The route-level facts model materialization reads. */ @@ -99,6 +98,10 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Context capacity for a model neither the entry nor the catalog sizes. */ + defaultContextWindow: number + /** Output capability for a model neither the entry nor the catalog sizes. */ + defaultMaxTokens: number } /** Report a route the deployment cannot serve, naming the settings key at fault. */ @@ -177,19 +180,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { if (baseUrl === undefined) { invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`) } - const contextWindow = entry.contextWindow ?? base?.contextWindow - if (contextWindow === undefined) { - invalid(provider, `model "${entry.id}" needs a contextWindow; without it the harness cannot detect overflow` - + ' or size compaction') - } + // Capacities fall back to the route's own defaults, so a model listing that + // discloses nothing but ids still yields a serviceable route. The fallback + // is a guess by construction, which is why it is a configurable route field + // rather than a constant buried here. + const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow if (!Number.isInteger(contextWindow) || contextWindow <= 0) { invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`) } - const maxTokens = entry.maxTokens ?? base?.maxTokens - if (maxTokens === undefined) { - invalid(provider, `model "${entry.id}" needs a maxTokens; it is the output cap materialized into requests` - + ' that omit one') - } + const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens if (!Number.isInteger(maxTokens) || maxTokens <= 0) { invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`) } @@ -202,7 +201,10 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - reasoning: entry.reasoning ?? base?.reasoning ?? false, + // Reasoning rides the installed entry or is absent: a bare boolean would + // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell + // them, and no listing endpoint reports a model's reasoning protocol. + reasoning: base?.reasoning ?? false, input: base?.input ?? TEXT_ONLY, cost: base?.cost ?? NO_COST, contextWindow, diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 01a63efb4a..0406a6906b 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -28,6 +28,12 @@ import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +/** Context capacity assumed for a model neither configuration nor the catalog sizes. */ +export const DEFAULT_CONTEXT_WINDOW = 262_144 + +/** Output capability assumed for a model neither configuration nor the catalog sizes. */ +export const DEFAULT_MAX_TOKENS = 32_768 + export type { PiAiModelProfile } from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ @@ -52,6 +58,18 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Context capacity for a model this route lists that neither the entry nor + * the installed catalog sizes (default 262,144). A guess by construction, so + * a deployment whose gateway serves smaller models corrects it here. + */ + defaultContextWindow?: number + /** + * Output capability for a model this route lists that neither the entry nor + * the installed catalog sizes (default 32,768). This sizes the model; it + * never becomes a per-request cap on its own. + */ + defaultMaxTokens?: number /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ @@ -122,7 +140,6 @@ const modelProfile: z = z.object({ name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), - reasoning: z.boolean(), }) const profile = z.object({ @@ -132,6 +149,8 @@ const profile = z.object({ api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), + defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), thinkingBudgets, @@ -148,6 +167,22 @@ export const Config: z = z.object({ providers: z.dict(profile).default({}), }) +/** + * Reject a section this adapter could not serve. Registered as the settings + * namespace's validator, so an unserviceable profile is refused where it is + * *written* — `settings.mutate` answers `settings-rejected` with the offending + * route and model named — instead of being stored and then quietly disabling + * every route in the namespace. It stays a validator rather than a schema + * transform because the schema is also the shape a configuration surface + * renders and the value an absent section resolves to; wrapping it would break + * both. + * @param config - the resolved section to check. + * @throws Error naming the route and model that cannot be served. + */ +export function assertServiceable(config: Config): void { + resolveProfiles(config.providers) +} + /** Reject a pre-release profile shape, naming the replacement. */ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { const legacy = source as PiAiProviderProfile & { @@ -211,6 +246,8 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, + defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }) const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index bc0e77658c..96fd2f1d8a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -48,7 +48,7 @@ import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigu import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { catalogProviderIds } from './catalog.ts' -import { Config, resolveProfiles } from './config.ts' +import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' export { PiAiAdapter } from './adapter.ts' @@ -98,24 +98,24 @@ export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config let lastRaw: Config | undefined let lastGood: ReadonlyMap | undefined + /** + * The resolved profiles for the current configuration, memoized by the raw + * snapshot's identity — which is also what makes the adapter's own snapshot + * stable across operations that observe no change. + * + * No fallback for an unserviceable snapshot lives here: the section schema + * resolves the whole profile set, so a write that could not be served is + * refused where it is written, and the settings seam keeps a namespace's + * last good value for a stored section that fails. Anything reaching this + * point has already resolved once. + */ const profiles = (): ReadonlyMap => { const raw = current() if (raw === lastRaw && lastGood !== undefined) return lastGood - try { - const next = resolveProfiles(raw.providers) - lastRaw = raw - lastGood = next - return next - } catch (error) { - // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing catalog or bound checks: - // keep serving the last good profiles and say so once per bad snapshot. - if (lastGood === undefined) throw error - lastRaw = raw - ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') - ctx.logger.error(error) - return lastGood - } + const next = resolveProfiles(raw.providers) + lastRaw = raw + lastGood = next + return next } profiles() @@ -201,6 +201,10 @@ export function apply(ctx: Context, config: Config): void { ensureRegistrationFacts() installSettingsSection(ctx, NS, Config, config, { + // Refuse an unserviceable section where it is written: without this a + // schema-valid profile the adapter cannot serve would be stored and then + // silently disable every route in this namespace. + validate: assertServiceable, setSource: (source) => { current = source }, diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index 07ec533919..d69fd539e6 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -22,11 +22,8 @@ import { createProvider } from '@earendil-works/pi-ai' import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' -import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy' -import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy' import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' -import { piMessagesApi } from '@earendil-works/pi-ai/api/pi-messages.lazy' import { catalogProvider } from './catalog.ts' /** @@ -35,32 +32,34 @@ import { catalogProvider } from './catalog.ts' * factory uses, so a hand-declared route reaches exactly the implementation a * catalog route would. * - * The table is deliberately narrower than pi-ai's full streaming API set: it - * holds only the protocols a profile can *completely* describe with a key, an + * The table is deliberately narrow: the protocols a hand-declared route + * actually reaches for today, each completely describable with a key, an * endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a * region, Vertex needs a project, a location, and application-default - * credentials, Azure needs provider environment plus an api-version, and - * Codex authenticates through OAuth — none of which this configuration shape - * can express, so offering them would hand back a provider that cannot - * authenticate. Catalog routes still reach those protocols through their own - * provider; only an explicit override is refused. + * credentials, Azure needs provider environment plus an api-version, and Codex + * authenticates through OAuth — none of which this configuration shape can + * express, so offering them would hand back a provider that cannot + * authenticate. The remainder are absent for want of a consumer rather than a + * blocker: each is one line here once a deployment needs it. Catalog routes + * still reach every protocol through their own provider; only an explicit + * override is refused. */ const PROTOCOLS: Readonly ProviderStreams>> = { - 'anthropic-messages': anthropicMessagesApi, - 'google-generative-ai': googleGenerativeAIApi, - 'mistral-conversations': mistralConversationsApi, 'openai-completions': openAICompletionsApi, 'openai-responses': openAIResponsesApi, - 'pi-messages': piMessagesApi, + 'anthropic-messages': anthropicMessagesApi, } /** - * Every wire protocol a configured route may name, sorted for stable - * diagnostics and configuration surfaces. + * Every wire protocol a configured route may name, most-reached first. The + * order is the table's and therefore stable; a configuration surface offering + * a choice presents the first as its default, which is why the protocol a + * hand-declared gateway most often speaks — and the one endpoint interrogation + * can read — leads. * @returns the supported protocol identifiers. */ export function supportedProtocols(): readonly string[] { - return Object.keys(PROTOCOLS).sort() + return Object.keys(PROTOCOLS) } /** diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 8481420661..7269aa6f61 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -335,12 +335,11 @@ describe('provider profile lifecycle', () => { ReasoningEffortId('xhigh'), ReasoningEffortId('max'), ]) - await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], - }, - }) + // A catalog model without reasoning is the same case as a hand-declared + // one: pi-ai reports the single level `off`, which translates to omitting + // the reasoning option — exactly what naming no effort already does. The + // capability is reported unavailable rather than offering that control. + expect((await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')).reasoning).toBeUndefined() }) it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index b2684c1997..9129d213e2 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -99,6 +99,26 @@ describe('hand-declared providers', () => { }) }) + it('offers no reasoning control it could not honour', async () => { + const server = await mockServer([]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + // pi-ai reports a model with no reasoning metadata as supporting the single + // level `off`, but `off` is translated to *omitting* the reasoning option — + // byte-for-byte the same request as naming no effort — so a provider whose + // own default is to think would keep thinking with `off` selected. The + // capability is reported unavailable instead of offering that control. + expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined() + + // A catalog route is unaffected: its models carry the metadata that makes + // `off` actually disable thinking. + const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id)) + .toContain('off') + }) + 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`)) @@ -111,13 +131,44 @@ describe('hand-declared providers', () => { }) }) - it('rejects a model whose capacity the catalog cannot supply', () => { + it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => { + const resolved = resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + // A listing endpoint that discloses nothing but ids still yields a + // serviceable route. + models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }], + }, + 'tuned-gateway': { + api: 'openai-completions', + baseURL: 'https://tuned.test', + defaultContextWindow: 4096, + defaultMaxTokens: 256, + models: [{ id: 'bare' }], + }, + }) + const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] => + resolved.get(route)?.piProvider.getModels() ?? [] + + expect(modelsOf('acme-gateway')).toMatchObject([ + { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 }, + { id: 'sized', contextWindow: 8192, maxTokens: 512 }, + ]) + // The fallback is a guess, so a deployment whose gateway serves smaller + // models corrects it once for the whole route. + expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }]) + // Only an explicitly configured cap is a request default; a fallback is + // the model's capability and stops there. + expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined() + expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512) + }) + + it('rejects a model the route cannot identify', () => { const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) => () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } }) - expect(declare({ id: 'acme-large', maxTokens: 1 })).toThrow(/needs a contextWindow/) - expect(declare({ id: 'acme-large', contextWindow: 1 })).toThrow(/needs a maxTokens/) - expect(declare({ id: '', contextWindow: 1, maxTokens: 1 })).toThrow(/empty id/) + expect(declare({ id: '' })).toThrow(/empty id/) expect(() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..c5eb230419 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -146,13 +146,16 @@ describe('request-level dynamic profiles', () => { expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) - it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => { const dir = await home() const ctx = await boot(dir, { providers: { openai: {} } }) - // Schema-valid but catalog-invalid: the resolver rejects it and the - // last good route set keeps serving. - await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) + // Shape-valid but unserviceable: a route the catalog does not ship and + // that lists no models of its own. The section schema resolves the whole + // profile set, so this is refused where it is written rather than stored + // and then quietly disabling every route in the namespace. + await expect(ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })) + .rejects.toThrow(/resolves no models/) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 3e54db480f..a680447ec7 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -176,7 +176,7 @@ describe('configurable-provider directory', () => { ctx.llm.registerConfigurableProviders([entry({ provider: 'owned-elsewhere' })]) // A candidate another registration already declares refuses the whole swap. - expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]); }).toThrow(/already declared/) + expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]) }).toThrow(/already declared/) expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort()) .toEqual(['owned-elsewhere', 'second', entry().provider].sort()) @@ -193,7 +193,7 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere']) handle() - expect(() =>{ handle.replace([entry()]); }).toThrow(/was disposed/) + expect(() =>{ handle.replace([entry()]) }).toThrow(/was disposed/) }) it('rejects duplicates within one registration and across registrations', async () => { diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index f51e08cb03..a617a73d7c 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -44,6 +44,23 @@ export interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * A stored section that fails this keeps the namespace's last good value and + * warns, exactly as a schema failure does, so an externally edited document + * can never strand the owner. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** One registered namespace as surfaced to configuration UIs. */ @@ -343,6 +360,8 @@ interface SettingsRegistration { schema: z base: unknown applies: SettingsApplies + /** Owner-supplied check for constraints the schema cannot express. */ + validate?: (value: unknown) => void resolved: unknown /** * Monotonic counter over this namespace's RAW user section — bumped by any @@ -456,7 +475,10 @@ export abstract class Settings extends Service { schema: schema as z, base: options?.base, applies: options?.applies ?? 'live', - resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + ...options?.validate === undefined + ? {} + : { validate: options.validate as (value: unknown) => void }, + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)), revision: 0, watchers: new Set(), } @@ -642,7 +664,7 @@ export abstract class Settings extends Service { : mode === 'replace' ? snapshot : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current) - const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate)) await this.persist(ns, section) // The write reached storage either way; the cache must say so. Commit // only when this registration is still the namespace owner — a fiber @@ -684,7 +706,7 @@ export abstract class Settings extends Service { for (const registration of this.registrations.values()) { let next: unknown try { - next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate)) } catch (error) { this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) this.ctx.logger.warn(error) @@ -706,10 +728,19 @@ export abstract class Settings extends Service { } /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ - private resolve(schema: z, base: unknown, section: Record | undefined): T { + private resolve( + schema: z, + base: unknown, + section: Record | undefined, + validate?: (value: T) => void, + ): T { // The merged candidate is untyped by construction; the schema call is the // runtime validation that admits it into T. - return schema(mergeLayers(base, section) as never) + const value = schema(mergeLayers(base, section) as never) + // The owner's own check runs on the admitted value, so it sees defaults + // and the composition base exactly as the owner will. + validate?.(value) + return value } /** @@ -842,6 +873,12 @@ export interface SettingsSectionHooks { * memoized resolutions — after an attach, a detach, or a committed change. */ onChange(): void + /** + * Reject a resolved section this consumer could not act on, for constraints + * its schema cannot express. See {@link SettingsRegisterOptions.validate}. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** @@ -865,7 +902,10 @@ export function installSettingsSection( hooks: SettingsSectionHooks, ): void { ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(ns, schema, { base: entry }) + const scope = sctx.settings.register(ns, schema, { + base: entry, + ...hooks.validate === undefined ? {} : { validate: hooks.validate }, + }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { // This disposer runs for two different reasons. A settings provider diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index dd3d5e1bc3..294c933a89 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -95,6 +95,31 @@ describe('registration', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) }) + it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => { + const { ctx } = await boot() + const ns = settingsNamespace('ui-theme') + // A constraint the schema cannot express: this owner cannot serve a size + // it considers unreadable, whatever the schema admits. + const scope = ctx.settings.register(ns, ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + }) + const before = scope.get() + + await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/) + expect(scope.get()).toEqual(before) + + // An externally edited document must not strand the owner: the namespace + // keeps its last good value, exactly as a schema failure would. + ;(ctx.settings as unknown as { publish(doc: Record): void }) + .publish({ 'ui-theme': { fontSize: 4 } }) + expect(scope.get()).toEqual(before) + + await ctx.settings.update(ns, { fontSize: 18 }) + expect(scope.get()).toMatchObject({ fontSize: 18 }) + }) + it('rejects a duplicate namespace loud', async () => { const { ctx } = await boot() ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) From 236b1f6d9783d80937550f6785ba515f753bd9bb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 14:47:09 +0800 Subject: [PATCH 05/27] docs(llm): record that non-reasoning catalog models lose the lone off level The adapter omits the seam's reasoning field whenever a model carries no reasoning metadata, which is the model's own property and says nothing about where the model came from. Both the JSDoc and the Agent Note read as though only hand-declared models were meant, so a reader would infer that the 251 installed-catalog models pi-ai marks as non-reasoning still offer their single off level. They do not, and that is the point: a picker holding only off misrepresents a provider that thinks by default, because off dispatches the same bytes as naming no effort at all. Behavior is unchanged; only the prose that describes it was narrower than the contract. adapter.spec.ts already pins the catalog case through openai/gpt-4.1 and catalog.spec.ts pins the hand-declared one. --- ...03-pi-ai-declared-provider-catalog.i18n.yaml | 4 ++-- ...026-08-03-pi-ai-declared-provider-catalog.md | 6 ++++++ ...-08-03-pi-ai-declared-provider-catalog.zh.md | 6 ++++++ packages/llm/llm-pi-ai/src/adapter.ts | 17 +++++++++-------- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 738dc42275..3415bf34c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: 343b54c5a09be17c0e4c31c219c01b0f1119847a -2026-08-03-pi-ai-declared-provider-catalog.zh.md: 8bc1fceb165cf6477be973944f0091db9bbe75f3 +2026-08-03-pi-ai-declared-provider-catalog.md: 3e926756dcf7d98eea7eb327b6722ff242fe245a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: 5385f0f309146b75ab17b0926d700b8fe66a11f8 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index 343b54c5a0..3e926756dc 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -31,6 +31,12 @@ Resolution fails loud and names the route and model at fault: a model the catalo The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. +### A capability whose only level does nothing is reported unavailable + +pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. + +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. + ### Credentials stay outside pi-ai pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index 8bc1fceb16..5385f0f309 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -31,6 +31,12 @@ Status: implemented 可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 +### 唯一档位什么也做不到的能力,报告为不可用 + +pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 + +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 + ### 凭据留在 pi-ai 之外 pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 2cdb7e6657..9b24c90a71 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -110,14 +110,15 @@ function resolveReasoningLevel( /** * Selectable reasoning efforts for one model, or nothing at all. * - * A model the installed catalog does not describe carries no reasoning - * metadata, and pi-ai reports that as the single level `off`. Passing that - * through would offer a control that cannot do what it says: `off` is - * translated to *omitting* the reasoning option, which for such a model is - * byte-for-byte the same request as naming no effort — so a provider whose own - * default is to think would keep thinking with `off` selected. Omitting - * `reasoning` entirely is the seam's way of saying the capability is - * unavailable, which leaves the surface offering only the provider's default. + * A model that carries no reasoning metadata — every hand-declared one, and + * every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as + * supporting the single level `off`. Passing that through would offer a control + * that cannot do what it says: `off` is translated to *omitting* the reasoning + * option, which for such a model is byte-for-byte the same request as naming no + * effort — so a provider whose own default is to think would keep thinking with + * `off` selected. Omitting `reasoning` entirely is the seam's way of saying the + * capability is unavailable, which leaves the surface offering only the + * provider's default. * @param model - the resolved model descriptor. * @param defaultLevel - the profile's configured effort, already validated. * @returns the `reasoning` field, or an empty object when none can be offered. From 73fce861e59291be57cf1c765e49d7e7bbe9482f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:01:20 +0800 Subject: [PATCH 06/27] fix(llm): let a catalog route keep the auth its provider actually declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally. --- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +- ...6-08-03-pi-ai-declared-provider-catalog.md | 12 +++--- ...8-03-pi-ai-declared-provider-catalog.zh.md | 12 +++--- docs/cordis-catalog/events.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/settings.i18n.yaml | 4 +- docs/core-data-structures/settings.md | 9 +++-- docs/core-data-structures/settings.zh.md | 9 +++-- docs/event-producer-consumer.md | 4 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 14 +------ packages/llm/llm-pi-ai/README.zh.md | 14 +------ packages/llm/llm-pi-ai/src/adapter.ts | 8 ++++ packages/llm/llm-pi-ai/src/catalog.ts | 14 ++++--- packages/llm/llm-pi-ai/src/config.ts | 1 + packages/llm/llm-pi-ai/src/index.ts | 28 ++++++++++--- packages/llm/llm-pi-ai/src/provider.ts | 39 ++++++++++++++++++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 37 +++++++++++++++++- packages/settings/settings/src/index.ts | 9 +++-- .../settings/settings/tests/settings.spec.ts | 13 +++++++ 20 files changed, 172 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 3415bf34c2..9300571e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: 3e926756dcf7d98eea7eb327b6722ff242fe245a -2026-08-03-pi-ai-declared-provider-catalog.zh.md: 5385f0f309146b75ab17b0926d700b8fe66a11f8 +2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 +2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index 3e926756dc..d75b6bdb91 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`, `reasoning`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning-level spellings, OpenAI-compatibility quirks, and model headers ride the installed entry, because restating them in configuration could not be validated. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -27,7 +27,7 @@ A provider route is a **declaration**, and the installed catalog is its default. The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving. -Resolution fails loud and names the route and model at fault: a model the catalog does not describe needs an explicit `contextWindow` and `maxTokens`; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. +Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own `defaultContextWindow`/`defaultMaxTokens`, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. @@ -41,7 +41,9 @@ pi-ai reports a model with no reasoning metadata as supporting the single level pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. -`ModelsImpl.applyAuth` treats `options.apiKey` as the highest-priority auth override, short-circuiting resolution entirely. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. A catalog route reuses the installed provider's `auth`, which preserves its provider-native ambient discovery for a profile naming no credential. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. +`ModelsImpl.applyAuth` honours `options.apiKey` as the request's key, but only through a provider that declares an api-key method: `resolveProviderAuth` short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with `Provider is not configured` — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. + +A route's auth follows from that. A catalog route keeps the installed provider's own `auth`, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an `api` override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — `openai-codex` authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. ## Alternatives considered @@ -58,8 +60,8 @@ pi-ai's `Models` carries its own credential concept — a `CredentialStore` keye Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata. -What it costs: `settings.yaml` grows for a declared route, because a model the catalog cannot default must state its own capacity. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. +What it costs: `settings.yaml` grows for a declared route, because it must state its endpoint, protocol, and model ids. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. ## Testing -`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index 5385f0f309..f8dba9900b 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`、`reasoning`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。思考级别拼写、OpenAI 兼容性怪癖与模型标头沿用已安装条目,因为在配置里重述它们无法被校验。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -27,7 +27,7 @@ Status: implemented 可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。 -解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型需要显式的 `contextWindow` 与 `maxTokens`;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 +解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 `defaultContextWindow`/`defaultMaxTokens`,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 @@ -41,7 +41,9 @@ pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适 pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 -`ModelsImpl.applyAuth` 把 `options.apiKey` 视为优先级最高的 auth 覆盖,会整条短路掉解析。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。catalog 路由复用已安装提供方的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 +`ModelsImpl.applyAuth` 会把 `options.apiKey` 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:`resolveProviderAuth` 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 `Provider is not configured` 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。 + +路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 `api` 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式的属性。例外是没有 api-key 方法的 catalog 提供方——`openai-codex` 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 ## Alternatives considered @@ -58,8 +60,8 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred 配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。 -代价是:声明式路由会让 `settings.yaml` 变长,因为 catalog 无法默认的模型必须自报容量。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 +代价是:声明式路由会让 `settings.yaml` 变长,因为它必须自报端点、协议与模型 id。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 ## Testing -`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 423f5bc9cb..c6f6bca511 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -726,7 +726,7 @@ One registered namespace's RAW user section changed, whether or not the resolved Types: [SettingsNamespace](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:167`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) ### `settings/updated` — emit @@ -753,7 +753,7 @@ Committed change to one registered namespace's resolved value. Emitted after the Types: [SettingsNamespace](../core-data-structures/settings.md) · [SettingsUpdateSource](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:154`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) ## `skills/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d2b679657f..cf5addb98c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1813,7 +1813,7 @@ async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevi Types: [SettingsDescribeOptions](../core-data-structures/settings.md) · [SettingsDescriptor](../core-data-structures/settings.md) · [SettingsNamespace](../core-data-structures/settings.md) · [SettingsPathOp](../core-data-structures/settings.md) · [SettingsRegisterOptions](../core-data-structures/settings.md) · [SettingsScope](../core-data-structures/settings.md) -Source: [`packages/settings/settings/src/index.ts:384`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/settings.i18n.yaml b/docs/core-data-structures/settings.i18n.yaml index bc8a9893b8..cf262dc1e9 100644 --- a/docs/core-data-structures/settings.i18n.yaml +++ b/docs/core-data-structures/settings.i18n.yaml @@ -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 docs/core-data-structures/settings.md -settings.md: 08f99b5e65ac4a57cdfff3323c0d9148d379bed8 -settings.zh.md: 08903810b44c293944950a8f5ae2710f45678d60 +settings.md: bd01c1d28407af9cab26f624a054a010e25a3ddd +settings.zh.md: 1cb7f8b507f29f2b6876fd48b4c37284df235e53 diff --git a/docs/core-data-structures/settings.md b/docs/core-data-structures/settings.md index 08f99b5e65..bd01c1d284 100644 --- a/docs/core-data-structures/settings.md +++ b/docs/core-data-structures/settings.md @@ -37,9 +37,12 @@ interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/docs/core-data-structures/settings.zh.md b/docs/core-data-structures/settings.zh.md index 08903810b4..1cb7f8b507 100644 --- a/docs/core-data-structures/settings.zh.md +++ b/docs/core-data-structures/settings.zh.md @@ -37,9 +37,12 @@ interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7fe303e0ef..8b7668cf07 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -38,8 +38,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:167`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:154`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 89cecdfdec..aa11c863ff 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: 884eadbde73f17ffd50994e09c975cca561d81ed -README.zh.md: af8a620eeccb2b971c4550bdcd7c8af93d5d4f90 +README.md: b8e5ed7b056295d975629ffdf444d353b306fcf7 +README.zh.md: bf8ba431d13f7bf90c75ca2000e0320a8506253b diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index a8d425a4bd..07de1c0ace 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. @@ -79,16 +79,6 @@ Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseU The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. -## Endpoint interrogation - -The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves. - -A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. - -Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. - -Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. - ## Provider/model routing and replay Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. @@ -151,7 +141,7 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). -- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Endpoint interrogation is an explicit action a configuration surface takes over a draft; nothing re-runs it, and adopting its result is a settings write like any other. +- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. - **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 00a20c5e60..0e8895a019 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 @@ -79,16 +79,6 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 -## 端点询问 - -插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。 - -点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 - -询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 - -多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 - ## 提供方/模型路由与回放 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 @@ -151,7 +141,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。端点询问是配置界面针对草稿主动发起的动作;没有任何环节会重跑它,采纳其结果与任何其他 settings 写入无异。 +- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 - **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 9b24c90a71..365c3901a5 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -40,6 +40,7 @@ import { import type { GenerateOptions, LlmModelInfo, + LlmProviderInfo, LlmResolvedModelInfo, ReasoningEffortId as ReasoningEffortIdType, ResolvedRetryPolicy, @@ -196,6 +197,13 @@ export class PiAiAdapter extends LlmAdapter { return resolved } + override providerInfo(provider: string): LlmProviderInfo { + // The configured name, not the route key: `displayName` exists so a + // deployment can label a route, and a label only the configuration surface + // reads would leave every selector showing the raw key. + return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider } + } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { return this.current().profiles.get(provider)?.retryPolicy } diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 2ac66b5a5e..173b84dd7d 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -196,6 +196,14 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // the model's capability and stays out of request defaults. if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens) return { + // The installed entry lays the floor, and the fields below override it. + // Enumerating instead would silently drop every `Model` field this + // package does not model — reasoning-level spellings, compatibility + // quirks, model headers, and whatever a pi-ai upgrade adds next. That is + // not hypothetical: `headers` reached this file only after an nvidia + // route lost it, and a rebuild keeps re-earning that bug on every + // upgrade. + ...base, id: entry.id, name: entry.name ?? base?.name ?? entry.id, api, @@ -209,12 +217,6 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { cost: base?.cost ?? NO_COST, contextWindow, maxTokens, - // Catalog-only metadata: reasoning-level spellings and OpenAI-compatibility - // quirks have no configuration surface, so they ride the catalog entry or - // are absent for a model pi-ai has never described. - ...base?.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: base.thinkingLevelMap }, - ...base?.compat === undefined ? {} : { compat: base.compat }, - ...base?.headers === undefined ? {} : { headers: base.headers }, } }) return { models, configuredMaxTokens } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 0406a6906b..7473dbb7ae 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -266,6 +266,7 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, models: catalog.models, + namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined, }), }) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 96fd2f1d8a..e55427e302 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -69,7 +69,14 @@ const NS = settingsNamespace('llm-pi-ai') */ function registrationFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] - .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + // `displayName` rides along because the registry hands it to every selector + // through `providerInfo()`: a rename that did not re-register would leave + // the old label showing until some unrelated fact happened to change. + .map(([provider, profile]) => ({ + provider, + displayName: profile.displayName, + retryPolicy: profile.retryPolicy, + })) .sort((left, right) => left.provider.localeCompare(right.provider)) } @@ -97,7 +104,7 @@ function directoryEntries( export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config let lastRaw: Config | undefined - let lastGood: ReadonlyMap | undefined + let memoized: ReadonlyMap | undefined /** * The resolved profiles for the current configuration, memoized by the raw * snapshot's identity — which is also what makes the adapter's own snapshot @@ -111,10 +118,10 @@ export function apply(ctx: Context, config: Config): void { */ const profiles = (): ReadonlyMap => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood + if (raw === lastRaw && memoized !== undefined) return memoized const next = resolveProfiles(raw.providers) lastRaw = raw - lastGood = next + memoized = next return next } profiles() @@ -209,7 +216,18 @@ export function apply(ctx: Context, config: Config): void { current = source }, onChange: () => { - ensureRegistrationFacts() + // Named here rather than left to the settings watcher: `assertServiceable` + // cannot see the llm registry, so a profile claiming a route another + // adapter family owns is stored successfully and only fails at this swap. + // Without its own diagnostic that refusal reaches the operator as a + // generic "settings: watcher failed", naming neither the route nor why it + // is not serving. The previous routes keep serving either way. + try { + ensureRegistrationFacts() + } catch (error) { + ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update') + ctx.logger.error(error) + } // The directory follows the profiles the registry accepted, so a route // that failed to register is not advertised as configurable. A refused // directory swap is contained here for the same reason the registry's diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts index d69fd539e6..893199d8fa 100644 --- a/packages/llm/llm-pi-ai/src/provider.ts +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -96,6 +96,41 @@ export interface ProviderSpec { baseURL?: string /** The route's materialized models, in configuration order. */ models: readonly Model[] + /** + * Whether the profile names a credential — a literal key or a reference. + * Only that decides whether {@link routeAuth} adds the harness's own api-key + * method to a catalog provider that offers none; the key itself still arrives + * per request, never at construction. + */ + namesCredential: boolean +} + +/** + * The auth one route resolves its credential through. + * + * A catalog route keeps the installed provider's own auth, which is what + * preserves provider-native ambient discovery for a profile naming no + * credential. That holds even when the profile repoints the protocol: which + * environment a provider reads is a property of the provider, not of the wire + * format its models speak. + * + * The single addition covers a catalog provider that offers no api-key method + * at all. pi-ai resolves a request's `apiKey` override only when the provider + * declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before + * honouring the override), so an OAuth-only provider — `openai-codex` is the + * one the installed catalog ships — would refuse a profile's explicit key with + * `Provider is not configured` before any request went out. Adding the harness + * method beside the provider's own restores that route. A keyless profile adds + * nothing and still reports the honest refusal, because this adapter resolves + * credentials through its own seam and holds no OAuth store to fall back on. + * @param spec - the resolved route facts. + * @param catalog - the installed catalog provider, when pi-ai ships one. + * @returns the auth to construct this route's provider with. + */ +function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] { + if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) } + if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth + return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) } } /** @@ -113,7 +148,7 @@ function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider { id: spec.provider, name: spec.displayName, ...baseUrl === undefined ? {} : { baseUrl }, - auth: base.auth, + auth: routeAuth(spec, base), getModels: () => spec.models, // Delegated rather than copied: the catalog provider stays the receiver, so // an implementation holding state on itself keeps working. @@ -149,7 +184,7 @@ export function buildProvider(spec: ProviderSpec): Provider { id: spec.provider, name: spec.displayName, ...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL }, - auth: { apiKey: harnessApiKeyAuth(spec.displayName) }, + auth: routeAuth(spec, catalog), models: spec.models, api: factory(), }) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 9129d213e2..1aded64568 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -10,6 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { createModels } from '@earendil-works/pi-ai' +import type { Api, Model, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' @@ -195,13 +197,13 @@ describe('hand-declared providers', () => { // endpoint, and headers can carry, so a route naming one would be built // unable to authenticate. expect(supportedProtocols()).not.toContain(api) - expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [] })) + expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true })) .toThrow(/cannot serve; supported protocols are/) }, ) it('rejects a protocol this build cannot serve, and a route that names none', () => { - const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] } + const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true } expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' })) .toThrow(/cannot serve; supported protocols are/) expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/) @@ -430,6 +432,37 @@ describe('catalog routes with per-model configuration', () => { await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(server.paths).toEqual(['/v1/chat/completions']) }) + + it('keeps the catalog provider’s own auth when the route repoints its protocol', () => { + // Which environment a provider reads is a property of the provider, not of + // the wire format its models speak: naming an api must not cost a profile + // its provider-native discovery. + const resolved = resolveProfiles({ openai: { api: 'openai-completions' } }) + expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key') + }) + + it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => { + // pi-ai honours a request's `apiKey` override only when the provider + // declares an api-key method. `openai-codex` ships OAuth alone, so without + // the harness method beside it the route refuses its own configured key as + // `Provider is not configured` before any request goes out. + const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } }) + const provider = resolved.get('openai-codex')?.piProvider + expect(provider?.auth.oauth).toBeDefined() + const models = createModels() + models.setProvider(provider as Provider) + const model = provider?.getModels()[0] as Model + const auth = await models.getAuth(model, { apiKey: 'codex-token' }) + expect(auth?.auth.apiKey).toBe('codex-token') + }) + + it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => { + // Nothing to add: this adapter resolves credentials through its own seam + // and holds no OAuth store, so declaring the provider configured would + // trade a truthful refusal for an endpoint's 401. + const resolved = resolveProfiles({ 'openai-codex': {} }) + expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined() + }) }) describe('resolution snapshots', () => { diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index a617a73d7c..64e9f147b1 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -55,9 +55,12 @@ export interface SettingsRegisterOptions { * configuration surface renders and what an absent section resolves through; * folding a cross-field check into it would change both. * - * A stored section that fails this keeps the namespace's last good value and - * warns, exactly as a schema failure does, so an externally edited document - * can never strand the owner. + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. * @param value - the resolved section, schema-valid by construction. */ validate?: (value: T) => void diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 294c933a89..c6a1e57016 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -120,6 +120,19 @@ describe('registration', () => { expect(scope.get()).toMatchObject({ fontSize: 18 }) }) + it('fails the registration itself when the already-stored section is unserviceable', async () => { + // The other direction of the same contract: `register` resolves inline, so + // at cold start there is no last good value to keep. A stored section the + // owner cannot serve therefore refuses the registration rather than + // mounting an owner over configuration it rejects. + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + })).toThrow(/unreadable/) + }) + it('rejects a duplicate namespace loud', async () => { const { ctx } = await boot() ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) From 5d3ccdc5286c7e9f7370ffe1e6819a472fca3706 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:29:40 +0800 Subject: [PATCH 07/27] test(llm): cover both names providerInfo can report for a route --- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 7269aa6f61..44e36c9096 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -125,6 +125,23 @@ describe('PiAiAdapter provider routing', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('names a route by its displayName, and by its own key once the profiles drop it', () => { + const adapter = adapterOf({ 'acme-gateway': { + apiKey: 'k', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'acme-large' }], + } }) + expect(adapter.providerInfo('acme-gateway')).toEqual({ id: 'acme-gateway', name: 'Acme Gateway' }) + + // The registry and the profiles can disagree for a moment: a refused + // registration swap leaves the previous routes serving while resolution + // has already moved on, so a selector may ask about a route the current + // profiles no longer describe. It gets the key rather than nothing. + expect(adapter.providerInfo('departed')).toEqual({ id: 'departed', name: 'departed' }) + }) + it('rejects stop sequences rather than silently ignoring them', async () => { const server = await mockServer([]) const ctx = await harness(server.url) From 9948a37cbcfbbb2a865178c8fc753126edc86318 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 18:56:41 +0800 Subject: [PATCH 08/27] docs(llm): follow master's README hierarchy for the pi-ai adapter A documentation rescan on master moved this README's Testing section out of the package. This branch was still editing that section, so the rebase asked which structure wins; master's does, and the branch keeps only the Catalog resolution section its own change adds. Re-records the pair fingerprint against the merged text. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index aa11c863ff..3420b9d493 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: b8e5ed7b056295d975629ffdf444d353b306fcf7 -README.zh.md: bf8ba431d13f7bf90c75ca2000e0320a8506253b +README.md: 07de1c0aceeccff5f3f14a43c4888b4481c0293a +README.zh.md: 0e8895a0192c7f18e2b6ee8869896080f7ff49e2 From ecee93ec2668f8b187ee63f8ceefba6b76e5b3f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 10:14:46 +0800 Subject: [PATCH 09/27] feat(llm): interrogate a draft provider endpoint for its models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a pi-ai route became a declaration rather than a catalog lookup, adding an OpenAI-compatible gateway meant knowing its model ids up front. Most such endpoints publish that list at `GET /models`, but no seam operation could ask: every one is keyed by a registered provider route, and the provider being added has no route, no stored profile, and no stored credential — the endpoint and key are values in a form. Interrogation is therefore keyed by settings namespace, which a configuration surface already holds from the configurable-provider directory. `registerModelDiscovery` offers it per namespace, `discoverModels` asks, and the request carries the draft itself. The reply is candidates, not a catalog: every field but the id is optional because most listings disclose nothing else, and adopting one is a settings write like any other. Nothing here reads or writes settings or credentials, so `settings.yaml` still decides what a route serves. `llm.discoverModels` carries the same draft over the wire. Its apiKey is the third and last payload a secret may ride, and it is never stored, logged, or echoed; every refusal folds into `model-discovery-failed`, naming the endpoint asked but never the credential offered. The pi-ai side is a plain GET for OpenAI-compatible protocols only — their listing shape is the one gateways, self-hosted servers, and the official endpoints agree on. Others say so, sending the user to hand-entry rather than reporting a guessed shape as an empty provider. The reply is read under a four-megabyte ceiling held on the bytes actually received, because the endpoint is a URL the user typed. --- ...-provider-endpoint-interrogation.i18n.yaml | 6 + ...4-draft-provider-endpoint-interrogation.md | 50 +++++ ...raft-provider-endpoint-interrogation.zh.md | 50 +++++ docs/cordis-catalog/events.md | 4 +- docs/cordis-catalog/services.md | 38 +++- docs/event-producer-consumer.md | 4 +- .../client/connection/src/client/fixture.ts | 7 + packages/client/connection/tests/fake-api.ts | 1 + packages/client/runtime/tests/fake-api.ts | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 20 ++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 23 ++ packages/host/apiproxy/src/api/llm.schema.ts | 26 ++- packages/host/apiproxy/src/api/llm.ts | 34 +++ packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 9 + packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-config.spec.ts | 86 +++++++ .../apiproxy/tests/client-handler.spec.ts | 19 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + packages/llm/llm-pi-ai/src/discovery.ts | 207 +++++++++++++++++ packages/llm/llm-pi-ai/src/index.ts | 5 + .../llm/llm-pi-ai/tests/discovery.spec.ts | 211 ++++++++++++++++++ packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 5 + packages/llm/llm/README.zh.md | 5 + packages/llm/llm/src/index.ts | 80 +++++++ packages/llm/llm/src/types.ts | 33 +++ packages/llm/llm/tests/topology.spec.ts | 52 +++++ scripts/gen-cordis-catalog.ts | 2 + 34 files changed, 985 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md create mode 100644 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md create mode 100644 packages/llm/llm-pi-ai/src/discovery.ts create mode 100644 packages/llm/llm-pi-ai/tests/discovery.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml new file mode 100644 index 0000000000..0283d5f3a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +2026-08-04-draft-provider-endpoint-interrogation.md: 86b3148626fea90f1d87b80084f7cd4bafeeb1f8 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md new file mode 100644 index 0000000000..86b3148626 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -0,0 +1,50 @@ +# Agent Note: Interrogating a draft provider endpoint + +Status: implemented + +English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md) + +## Problem + +Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`. + +The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves. + +The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing seam operation is keyed by a registered provider route, so none of them can carry this. + +## Decision + +Interrogation is keyed by **settings namespace**, not by provider route: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns; `ctx.llm.listModelDiscoveryNamespaces()` lets a surface offer the action only where it works; `ctx.llm.discoverModels(settingsNs, request)` asks. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. +- `LlmModelDiscoveryRequest` carries the draft — `baseURL`, an optional `api`, an optional `apiKey`, and a signal. Nothing in this path reads or writes settings or credentials; the caller owns both. +- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. +- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. + +`dsh-llm-pi-ai` implements it as a plain `GET {baseURL}/models` for OpenAI-compatible protocols only. Their listing shape is the one a gateway, a self-hosted server, and the official endpoints all agree on, which is the case this action exists for. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. + +### Why not pi-ai's own refresh machinery + +pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. + +## Alternatives considered + +**Key interrogation by provider route.** Symmetric with every other seam operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least. + +**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve. + +**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical. + +**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback. + +**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed. + +## Consequences + +A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. + +What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md new file mode 100644 index 0000000000..0f6a63385d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 询问草稿中的提供方端点 + +Status: implemented + +[English](2026-08-04-draft-provider-endpoint-interrogation.md) | 中文 + +## Problem + +当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。 + +显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是**只问一次**,其答案由用户采纳进 `settings.yaml`——从而让 `settings.yaml` 始终是唯一决定路由服务什么的东西。 + +麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 seam 操作都以已注册的提供方路由为键,因此没有一个能承载它。 + +## Decision + +询问以 **settings namespace** 为键,而不是提供方路由: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力;`ctx.llm.listModelDiscoveryNamespaces()` 让界面只在可用之处提供该动作;`ctx.llm.discoverModels(settingsNs, request)` 发起询问。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 +- `LlmModelDiscoveryRequest` 携带草稿——`baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal。这条路径既不读也不写 settings 与 credentials;两者都归调用方所有。 +- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 +- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储、记录或回显。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 + +`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 + +### 为什么不用 pi-ai 自己的 refresh 机制 + +pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。 + +## Alternatives considered + +**以提供方路由为键。** 与其他每个 seam 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。 + +**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。 + +**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致。 + +**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 + +**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。 + +## Consequences + +接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、除 fiber 外没有生命周期。 + +代价是:协议层多了第三个承载 secret 的载荷,配置面的只写接口从两个方法变成三个。发现能力按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c6f6bca511..42fd161859 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -588,7 +588,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ### `llm/stream` — waterfall @@ -612,7 +612,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cf5addb98c..dc0c9fbcdc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -834,9 +834,9 @@ listProviders(): LlmProviderInfo[] * entry, or a provider already declared by any registration throws * `LlmError` without registering the rest. Disposed with the fiber. * @param entries - every configurable provider this plugin owns. - * @returns a handle that withdraws all of them, and can atomically replace them. + * @returns the disposer that withdraws all of them. */ -registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle +registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void /** * List every declared configurable provider, registered or dormant. @@ -844,6 +844,36 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire */ listConfigurableProviders(): LlmConfigurableProvider[] +/** + * Offer to interrogate provider endpoints on behalf of the settings + * namespace this plugin owns. The namespace is the key because that is what + * a configuration surface already holds from the configurable-provider + * directory, and because a provider being *added* has no route to name yet. + * Disposed with the fiber. + * @param settingsNs - the namespace whose profiles this discovery serves. + * @param discover - interrogates one endpoint; must honor `request.signal`. + * @returns the disposer that withdraws the offer. + */ +registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void + +/** + * List the settings namespaces that can interrogate a provider endpoint, so + * a surface can offer the action only where it will work. + * @returns the namespaces in registration order. + */ +listModelDiscoveryNamespaces(): string[] + +/** + * Interrogate one provider endpoint for the models it advertises. The + * request describes a draft, not a stored route, so nothing here reads or + * writes settings or credentials — the caller owns both, and the reply is + * candidate metadata a surface may offer for adoption. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - the endpoint, protocol, and one-shot credential to use. + * @returns the advertised models, deduplicated in endpoint order. + */ +async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise + /** * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. @@ -908,9 +938,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:253`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:234`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8b7668cf07..63a82a1afa 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 2ae4a31317..222bd4b125 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2444,6 +2444,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 { // Same routing discipline as the host: rpcId first, then the payload's @@ -2561,6 +2567,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) } } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6c7acbf9f7..e5eb42695d 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -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). */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 888a630de1..e50574d102 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -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). */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7feea98742..1e1230935d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -428,6 +428,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listConfigurableProviders(): LlmConfigurableProvider[]', jsDoc: '/**\n * List every declared configurable provider, registered or dormant.\n * @returns detached directory entries in declaration order.\n */', }, + { + signature: 'registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void', + jsDoc: '/**\n * Offer to interrogate provider endpoints on behalf of the settings\n * namespace this plugin owns. The namespace is the key because that is what\n * a configuration surface already holds from the configurable-provider\n * directory, and because a provider being *added* has no route to name yet.\n * Disposed with the fiber.\n * @param settingsNs - the namespace whose profiles this discovery serves.\n * @param discover - interrogates one endpoint; must honor `request.signal`.\n * @returns the disposer that withdraws the offer.\n */', + }, + { + signature: 'listModelDiscoveryNamespaces(): string[]', + jsDoc: '/**\n * List the settings namespaces that can interrogate a provider endpoint, so\n * a surface can offer the action only where it will work.\n * @returns the namespaces in registration order.\n */', + }, + { + signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise', + jsDoc: '/**\n * Interrogate one provider endpoint for the models it advertises. The\n * request describes a draft, not a stored route, so nothing here reads or\n * writes settings or credentials — the caller owns both, and the reply is\n * candidate metadata a surface may offer for adoption.\n * @param settingsNs - namespace whose registered discovery serves this draft.\n * @param request - the endpoint, protocol, and one-shot credential to use.\n * @returns the advertised models, deduplicated in endpoint order.\n */', + }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */', @@ -2097,6 +2109,10 @@ 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}', }, + { + name: 'LlmDiscoveredModel', + declaration: 'export interface LlmDiscoveredModel {\n id: string;\n name?: string;\n contextWindow?: number;\n maxTokens?: number;\n}', + }, { name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', @@ -2105,6 +2121,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmModelContext', declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}', }, + { + name: 'LlmModelDiscoveryRequest', + declaration: 'export interface LlmModelDiscoveryRequest {\n baseURL: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 845f597d64..dc6f498d56 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: b0364161a30c42e1fbb1f3bb67e73a15c83c0e3c -README.zh.md: 29d4678edcecbab0795760c25f4a286bfac9dc1b +README.md: 633c8fe39d989802e8debc350279137b66979593 +README.zh.md: 5e5102840319900f6607acc17288882ccf3c0075 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b0364161a3..fe48218b3d 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,7 +38,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third and last payload a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and is never stored, logged, or echoed. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 29d4678edc..72538a6cfb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带生成的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储、记录或回显。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 `subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 33225d15ed..11295715fe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2588,6 +2588,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async models(request) { return ok(request, await buildModelCatalog(ctx)) }, + + async discoverModels(request, signal) { + const { settingsNs, baseURL, api, apiKey } = request.payload + try { + const models = await ctx.llm.discoverModels(settingsNs, { + baseURL, + ...api === undefined ? {} : { api }, + ...apiKey === undefined ? {} : { apiKey }, + ...signal === undefined ? {} : { signal }, + }) + return ok(request, { models }) + } catch (error: unknown) { + // Every failure here is the user's next move, not a transport fault: + // a wrong endpoint, a rejected key, or a protocol with no listing all + // end at the same place — fill the models in by hand. The details + // repeat only what the caller already sent, never the credential. + return err(request, { + code: 'model-discovery-failed', + message: error instanceof Error ? error.message : String(error), + details: { settingsNs, baseURL }, + }) + } + }, }, events: { diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 4d86302c9f..44308ec186 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { ConfigurableProviderView } from './llm.ts' +import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts' /** ConfigurableProviderView row of llm.providers. */ @@ -34,3 +34,27 @@ export const llmModelsValueSchema = z.object({ groups: z.array(modelProviderGroupSchema), failures: z.array(modelCatalogFailureSchema), }) satisfies z.ZodType>> + +/** DiscoveredModelView row of llm.discoverModels. */ +export const discoveredModelViewSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), +}) satisfies z.ZodType> + +/** llm.discoverModels request payload. */ +export const llmDiscoverModelsRequestSchema = z.object({ + settingsNs: z.string().min(1), + baseURL: z.string().min(1), + api: z.string().min(1).optional(), + // Write-only: the host uses it for this one interrogation and never stores, + // logs, or returns it. Kept out of any redacted echo for the same reason + // `credentials.set` never reads a value back. + apiKey: z.string().min(1).optional(), +}) satisfies z.ZodType>> + +/** llm.discoverModels response value. */ +export const llmDiscoverModelsValueSchema = z.object({ + models: z.array(discoveredModelViewSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index a62319fd62..818c5c441c 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -40,4 +40,38 @@ export interface LlmApi { * failures ride `failures` without failing the sound groups. */ models(request: RpcRequest<{}>): Promise> + + /** + * Interrogate a provider endpoint the configuration surface is still + * drafting, and return the models it advertises for the user to adopt. + * + * The payload is the draft, not a stored route: `settingsNs` selects the + * adapter family that knows how to read the listing, and the endpoint, + * protocol, and key come from the form. Nothing is written — the reply is + * candidates, and only a later `settings.mutate` decides what a route + * serves. `apiKey` is therefore accepted here but never stored, logged, or + * echoed back; a provider whose key is already stored omits it and the + * endpoint answers unauthenticated or refuses. + */ + discoverModels( + request: RpcRequest<{ + settingsNs: string + baseURL: string + api?: string + apiKey?: string + }>, + signal?: AbortSignal, + ): Promise> +} + +/** Wire view of one model an interrogated endpoint advertises. */ +export interface DiscoveredModelView { + /** Model id the endpoint accepts. */ + id: string + /** Human-readable name when the endpoint supplies one. */ + name?: string + /** Maximum combined request and response context, when disclosed. */ + contextWindow?: number + /** Maximum output tokens, when disclosed. */ + maxTokens?: number } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 0378f885e1..9a8750c722 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -66,6 +66,7 @@ export interface RpcMethodMap { 'credentials.unset': CredentialsApi['unset'] 'llm.providers': LlmApi['providers'] 'llm.models': LlmApi['models'] + 'llm.discoverModels': LlmApi['discoverModels'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 4424b5527c..90972ee78b 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -55,6 +55,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), + z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index f435f23e93..c0e2f27c96 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -70,6 +70,15 @@ export interface RpcErrorDetailsMap { 'settings-conflict': { ns: string; expected: number; actual: number } /** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */ 'credential-rejected': { ref: string } + /** + * Interrogating a draft provider endpoint did not produce a model listing: + * no adapter family serves the namespace, the protocol has no listing this + * build can read, or the endpoint was unreachable, refused the credential, + * or answered with something else. The message is the adapter's own text — + * it is what the form shows before falling back to hand-entry — and the + * details name the endpoint asked, never the credential offered. + */ + 'model-discovery-failed': { settingsNs: string; baseURL: string } 'title-invalid': { sessionId: SessionId } 'fork-unavailable': { sessionId: SessionId } 'subagent-parent-unavailable': { parentSessionId: SessionId } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0aa630b328..0f54d76dbc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -55,7 +55,7 @@ import { import { credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, } from '../api/credentials.schema.ts' -import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' +import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { subagentHistoryValueSchema, subagentListValueSchema, @@ -146,6 +146,7 @@ export interface IApiClient { llm: { providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise>> models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> + discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise>> } /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ respond(message: ClientResponse, signal?: AbortSignal): Promise @@ -200,6 +201,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('llm.providers', payload, signal), models: (payload, signal) => this.callUnary('llm.models', payload, signal), + discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8feffc63b6..d41b51ad6d 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -57,7 +57,7 @@ import { import { credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, } from '../api/credentials.schema.ts' -import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' +import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { subagentHistoryRequestSchema, subagentListRequestSchema, @@ -125,6 +125,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) }, 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) }, 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, + 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index ca79ae367d..8be2009cca 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -560,3 +560,89 @@ describe('llm domain', () => { expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }]) }) }) + +describe('llm.discoverModels', () => { + it('carries a draft to its namespace and returns candidates without storing anything', async () => { + const ctx = await harness() + const seen: unknown[] = [] + ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => { + seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey }) + return Promise.resolve([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + const value = expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }))) + + expect(value.models).toEqual([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + expect(seen).toEqual([{ + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }]) + // Interrogating a draft is a read: no namespace gained a section, and no + // credential reference was written. + expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) + .not.toContain('llm-pi-ai') + }) + + it('omits a credential and protocol the draft does not name', async () => { + const ctx = await harness() + let probe: unknown + ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { + probe = request_ + return Promise.resolve([]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + }))) + + // Absent fields stay absent rather than crossing as explicit undefined: + // the adapter distinguishes "no protocol named" from "protocol undefined". + expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' }) + }) + + it('reports a failed interrogation as the form\'s next move, naming no credential', async () => { + const ctx = await harness() + ctx.llm.registerModelDiscovery('llm-pi-ai', () => + Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key'))) + const api = createApiProxy(ctx, DEFAULTS) + + const error = expectErr(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + apiKey: 'wrong', + }))) + + expect(error.code).toBe('model-discovery-failed') + expect(error.message).toContain('answered 401; check the API key') + expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' }) + expect(JSON.stringify(error)).not.toContain('wrong') + }) + + it('reports a namespace no adapter family serves', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + + const error = expectErr(await api.llm.discoverModels(request({ + settingsNs: 'llm-deepseek', + baseURL: 'https://api.deepseek.com', + }))) + + expect(error.code).toBe('model-discovery-failed') + expect(error.message).toContain('no model discovery is registered') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ded1e433b1..490e0ad7f1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -112,6 +112,7 @@ function scriptedApi(overrides: { llm: { providers: r => ok(r, { providers: [] }), models: r => ok(r, { groups: [], failures: [] }), + discoverModels: err, ...overrides.llm, }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, @@ -694,6 +695,7 @@ describe('config unary surface', () => { llm: { providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), models: record('llm.models', r => ok(r, { groups: [group], failures: [] })), + discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })), }, }) const c = client(api) @@ -719,16 +721,31 @@ describe('config unary surface', () => { expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) const models = await c.llm.models({}) expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } }) + const discovered = await c.llm.discoverModels({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }) + expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } }) expect(seen.map(call => call.method)).toEqual([ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', - 'llm.providers', 'llm.models', + 'llm.providers', 'llm.models', 'llm.discoverModels', ]) expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) expect(seen[4]?.payload) .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 }) expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) + // The draft crosses whole, credential included: the host needs it for this + // one interrogation and stores none of it. + expect(seen[10]?.payload).toEqual({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }) }) it('rejects an invalid credential reference name at the carrier boundary', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 7d41b41612..bcccfdd52e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -253,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async models(request) { return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } } }, + async discoverModels(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts new file mode 100644 index 0000000000..fb56e5645b --- /dev/null +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -0,0 +1,207 @@ +/** + * One-shot interrogation of a provider endpoint's model listing, serving the + * configuration surface's "fetch available models" action. + * + * This is deliberately *not* a catalog refresh. Nothing here is stored: the + * request carries a draft the user is still editing — an endpoint and a + * credential neither of which may exist in `settings.yaml` yet — and the reply + * is candidate metadata the surface offers for adoption. `settings.yaml` + * remains the only thing that decides what a route serves. + * + * Only OpenAI-compatible protocols are interrogated. Their listing is the one + * shape a gateway, a self-hosted server, and the official endpoints all agree + * on, which is the case this action exists for; every other protocol reports + * that it cannot be interrogated so the surface falls back to hand-entry + * rather than guessing a response shape. + * + * @module dsh-llm-pi-ai/discovery + */ + +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' +import { attributionHeaders } from '@deepseek-ai/dsh-llm' + +/** + * Protocols whose model listing this module can read. Every entry speaks + * OpenAI's `GET /models` shape; pi-ai's other protocols are absent because a + * wrong guess at their response shape would be reported as an empty provider + * rather than as the gap it is. + */ +const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ + 'azure-openai-responses', + 'openai-codex-responses', + 'openai-completions', + 'openai-responses', +]) + +/** + * Endpoint replies larger than this are refused. The endpoint is whatever URL + * the user typed, so the ceiling holds on the bytes actually read rather than + * on the length the server claims — the same two-stage shape `dsh-web-fetch` + * uses for its own caller-supplied URLs, except that a truncated model listing + * is not parseable, so overflow rejects instead of truncating. + */ +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +/** One entry of an OpenAI-compatible `GET /models` reply. */ +interface ListingEntry { + id?: unknown + /** Common gateway extensions; absent from the official listings. */ + name?: unknown + display_name?: unknown + context_window?: unknown + context_length?: unknown + max_tokens?: unknown + max_output_tokens?: unknown +} + +/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */ +function capacity(...candidates: readonly unknown[]): number | undefined { + for (const candidate of candidates) { + if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate + } + return undefined +} + +/** A non-empty string field of a listing entry, or `undefined`. */ +function label(...candidates: readonly unknown[]): string | undefined { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return undefined +} + +/** + * Join the endpoint base with the listing path. The base is treated as a + * prefix rather than a URL to resolve against, so a deployment path such as + * `https://gateway.example/openai/v1` keeps its segments instead of losing + * them to `URL` resolution. + */ +function listingUrl(baseURL: string): string { + return `${baseURL.replace(/\/+$/, '')}/models` +} + +/** + * Read a reply body, refusing one that outgrows the ceiling. A declared length + * is checked first so an honest server is turned away without transferring + * anything; the accumulated total is what actually enforces the bound, because + * a server that under-declares (or streams) tells us nothing up front. + */ +async function readBounded(response: Response, url: string): Promise { + const oversized = (): LlmError => + new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED') + const declared = Number(response.headers.get('content-length') ?? Number.NaN) + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + await response.body?.cancel() + throw oversized() + } + /* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */ + if (response.body === null) return '' + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > MAX_RESPONSE_BYTES) throw oversized() + chunks.push(value) + } + } finally { + /* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */ + await reader.cancel().catch(() => { + // Cancel after a drained read, or after this function walked away from + // an oversized one, is cleanup; the reply is already decided either way. + }) + } + const body = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +/** + * Read one OpenAI-compatible listing reply. Entries without a usable id are + * skipped rather than failing the whole interrogation: a single malformed row + * should not deny the user the rest of a working endpoint's catalog. + */ +function readListing(body: unknown): LlmDiscoveredModel[] { + const data = (body as { data?: unknown } | null)?.data + if (!Array.isArray(data)) { + throw new LlmError( + 'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand', + 'DISCOVERY_FAILED', + ) + } + const models: LlmDiscoveredModel[] = [] + for (const raw of data) { + const entry = raw as ListingEntry | null + const id = label(entry?.id) + if (id === undefined) continue + const name = label(entry?.name, entry?.display_name) + const contextWindow = capacity(entry?.context_window, entry?.context_length) + const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens) + models.push({ + id, + ...name === undefined ? {} : { name }, + ...contextWindow === undefined ? {} : { contextWindow }, + ...maxTokens === undefined ? {} : { maxTokens }, + }) + } + return models +} + +/** + * Interrogate one draft provider endpoint for the models it advertises. + * @param request - the endpoint, protocol, and one-shot credential to use. + * @returns the advertised models in endpoint order. + * @throws LlmError when the protocol has no readable listing, the endpoint + * refuses or fails the request, or the reply is not a model listing. + */ +export async function discoverModels( + request: LlmModelDiscoveryRequest, +): Promise { + const api = request.api ?? 'openai-completions' + if (!LISTABLE_PROTOCOLS.has(api)) { + throw new LlmError( + `pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`, + 'DISCOVERY_UNSUPPORTED', + ) + } + const url = listingUrl(request.baseURL) + let response: Response + try { + response = await fetch(url, { + method: 'GET', + headers: { + accept: 'application/json', + ...request.apiKey === undefined ? {} : { authorization: `Bearer ${request.apiKey}` }, + ...attributionHeaders(), + }, + ...request.signal === undefined ? {} : { signal: request.signal }, + }) + } catch (error: unknown) { + if (request.signal?.aborted) { + throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error }) + } + throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error }) + } + if (!response.ok) { + throw new LlmError( + `${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`, + 'DISCOVERY_FAILED', + ) + } + const text = await readBounded(response, url) + let body: unknown + try { + body = JSON.parse(text) + } catch (error: unknown) { + throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error }) + } + return readListing(body) +} diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index e55427e302..aac3ff5a62 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -50,6 +50,7 @@ import { PiAiAdapter } from './adapter.ts' import { catalogProviderIds } from './catalog.ts' import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' +import { discoverModels } from './discovery.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' @@ -176,6 +177,10 @@ export function apply(ctx: Context, config: Config): void { directoryFacts = entries } ensureDirectory() + // Interrogating an endpoint is a configuration-time action over a draft, so + // it is offered for the whole namespace rather than per route: the provider + // a surface is adding does not exist yet. + ctx.llm.registerModelDiscovery(NS, discoverModels) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts new file mode 100644 index 0000000000..c590ad44e1 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -0,0 +1,211 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +}) + +interface ListingServer { + url: string + paths: string[] + headers: IncomingMessage['headers'][] +} + +/** + * A stand-in provider that answers one scripted `GET /models`. `chunks` writes + * without a declared length, which is how a real streamed reply arrives. + */ +async function listingServer(behavior: { + status?: number + body?: string + chunks?: string[] +}): Promise { + const paths: string[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + paths.push(request.url ?? '') + headers.push(request.headers) + if (behavior.chunks !== undefined) { + // No declared length: the ceiling has to hold on what is read. + response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' }) + for (const chunk of behavior.chunks) response.write(chunk) + response.end() + return + } + const body = behavior.body ?? '{}' + response.writeHead(behavior.status ?? 200, { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(body)), + }) + response.end(body) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, paths, headers } +} + +/** A bare dormant mount: discovery is offered whether or not a route exists. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, {}) + return ctx +} + +describe('draft-provider model discovery', () => { + it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [ + { id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 }, + { id: 'acme-small' }, + ], + }), + }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' }) + + expect(models).toEqual([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + expect(server.paths).toEqual(['/v1/models']) + expect(server.headers[0]?.authorization).toBe('Bearer probe-key') + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + }) + + it('keeps a deployment path instead of resolving it away', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` }) + + expect(server.paths).toEqual(['/openai/v1/models']) + }) + + it('offers no credential when the draft names none', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }) + + expect(server.headers[0]?.authorization).toBeUndefined() + }) + + it('drops unusable rows rather than failing the whole listing', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [ + { id: 'good' }, + { id: '' }, + { name: 'no id at all' }, + null, + { id: 'good' }, + { id: 'zero-capacity', context_length: 0, max_tokens: -1 }, + ], + }), + }) + const ctx = await harness() + + expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) + .toEqual([{ id: 'good' }, { id: 'zero-capacity' }]) + }) + + it('points at the credential for a rejected one, and only then', async () => { + const ctx = await harness() + + for (const status of [401, 403]) { + const refused = await listingServer({ status, body: '{"error":"nope"}' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' })) + .rejects.toThrow(new RegExp(`answered ${status}; check the API key`)) + } + + // A server fault is not a credential problem, so it must not send the user + // off to re-check a key that is fine. + const broken = await listingServer({ status: 500, body: '{"error":"boom"}' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' })) + .rejects.toThrow(/answered 500$/) + }) + + it('reports a reply that is not a model listing', async () => { + const server = await listingServer({ body: '{"models":[]}' }) + const ctx = await harness() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) + .rejects.toThrow(/no "data" array; enter this provider's models by hand/) + + const broken = await listingServer({ body: 'not json at all' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url })) + .rejects.toThrow(/did not answer with JSON/) + }) + + it('refuses an oversized reply, whether its length is declared or streamed', async () => { + const ctx = await harness() + // Just over the four-megabyte ceiling, as one padded model row. + const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}` + + const declared = await listingServer({ body: oversized }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url })) + .rejects.toThrow(/answered with more than 4194304 bytes/) + + // A streamed reply declares no length, so the ceiling has to hold on the + // body the harness actually read. + const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url })) + .rejects.toThrow(/answered with more than 4194304 bytes/) + }) + + it('reports an unreachable endpoint instead of an empty catalog', async () => { + const ctx = await harness() + // Port 9 is the discard service: nothing accepts a connection there. + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' })) + .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) + }) + + it('says which protocols it cannot interrogate rather than guessing a shape', async () => { + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { + baseURL: 'https://gateway.example/v1', + api: 'anthropic-messages', + })).rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' }) + }) + + it('honors caller cancellation', async () => { + const ctx = await harness() + const aborted = AbortSignal.abort('test cancellation') + await expect(ctx.llm.discoverModels('llm-pi-ai', { + baseURL: 'http://127.0.0.1:9/v1', + signal: aborted, + })).rejects.toMatchObject({ code: 'ABORTED' }) + }) + + it('is offered for the namespace, and refuses one it does not serve', async () => { + const ctx = await harness() + + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai']) + await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + }) + + it('withdraws the offer when the plugin unloads', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const fiber = await ctx.plugin(LlmPiAi, {}) + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai']) + + await fiber.dispose() + + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([]) + }) +}) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 473187c895..5f7787cbd8 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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: e09ec685ed0ab1e2492749237c277a874eb3b246 -README.zh.md: ca98e875a90eb16e32bc405d77cd5b2b56644180 +README.md: 60cc94b6375030955136b4efaf969b69bca2530a +README.zh.md: 5b24a1e311c37d13dc4f287e5ae4b57efe00e4e6 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index e09ec685ed..500f0092c5 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -14,6 +14,9 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `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.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` Ask one endpoint which models it advertises. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. @@ -23,6 +26,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and an empty namespace or endpoint fails with `INVALID_DISCOVERY`. + Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index ca98e875a9..e6cf9742de 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -14,6 +14,9 @@ - `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.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` 询问某个端点它公布了哪些模型。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 @@ -23,6 +26,8 @@ `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 +询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,空 namespace 或空端点以 `INVALID_DISCOVERY` 失败。 + 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 4c1e8e94d5..f57ad46eec 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,8 +10,10 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmConfigurableProvider, + LlmDiscoveredModel, LlmFailure, LlmModelContext, + LlmModelDiscoveryRequest, LlmModelInfo, LlmResolvedModelInfo, LlmProviderInfo, @@ -253,6 +255,10 @@ export interface DirectoryRegistrationHandle { export class LlmService extends Service { private adapters = new Map() private directory = new Map() + private discoveries = new Map< + string, + (request: LlmModelDiscoveryRequest) => Promise + >() constructor(ctx: Context) { super(ctx, 'llm') @@ -456,6 +462,80 @@ export class LlmService extends Service { return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] })) } + /** + * Offer to interrogate provider endpoints on behalf of the settings + * namespace this plugin owns. The namespace is the key because that is what + * a configuration surface already holds from the configurable-provider + * directory, and because a provider being *added* has no route to name yet. + * Disposed with the fiber. + * @param settingsNs - the namespace whose profiles this discovery serves. + * @param discover - interrogates one endpoint; must honor `request.signal`. + * @returns the disposer that withdraws the offer. + */ + registerModelDiscovery( + settingsNs: string, + discover: (request: LlmModelDiscoveryRequest) => Promise, + ): () => void { + const dispose = this.ctx.effect(function* (this: LlmService) { + if (settingsNs.length === 0) { + throw new LlmError('model discovery needs a non-empty settings namespace', 'INVALID_DISCOVERY') + } + if (this.discoveries.has(settingsNs)) { + throw new LlmError(`model discovery for "${settingsNs}" is already registered`, 'DUPLICATE_DISCOVERY') + } + this.discoveries.set(settingsNs, discover) + yield () => { + this.discoveries.delete(settingsNs) + } + }.bind(this), 'llm.registerModelDiscovery()') + return () => void dispose() + } + + /** + * List the settings namespaces that can interrogate a provider endpoint, so + * a surface can offer the action only where it will work. + * @returns the namespaces in registration order. + */ + listModelDiscoveryNamespaces(): string[] { + return [...this.discoveries.keys()] + } + + /** + * Interrogate one provider endpoint for the models it advertises. The + * request describes a draft, not a stored route, so nothing here reads or + * writes settings or credentials — the caller owns both, and the reply is + * candidate metadata a surface may offer for adoption. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - the endpoint, protocol, and one-shot credential to use. + * @returns the advertised models, deduplicated in endpoint order. + */ + async discoverModels( + settingsNs: string, + request: LlmModelDiscoveryRequest, + ): Promise { + const discover = this.discoveries.get(settingsNs) + if (discover === undefined) { + throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY') + } + if (request.baseURL.length === 0) { + throw new LlmError('model discovery needs a non-empty baseURL', 'INVALID_DISCOVERY') + } + const discovered = await discover(request) + const seen = new Set() + const models: LlmDiscoveredModel[] = [] + for (const model of discovered) { + if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id)) continue + seen.add(model.id) + models.push({ + id: model.id, + ...model.name === undefined ? {} : { name: model.name }, + ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, + }) + } + return models + } + /** * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 83230f7079..220016cff0 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -139,6 +139,39 @@ export interface LlmConfigurableProvider { settingsPath: readonly string[] } +/** + * One interrogation of a provider endpoint that configuration has not stored + * yet. Configuration surfaces send the draft a user is still editing, so the + * request carries the endpoint and credential directly instead of naming a + * route: a provider being added has no route to name. + */ +export interface LlmModelDiscoveryRequest { + /** Endpoint to interrogate. */ + baseURL: string + /** Wire protocol the endpoint speaks, when the draft names one. */ + api?: string + /** Credential for this interrogation alone; the harness never stores it. */ + apiKey?: string + /** Caller cancellation; implementations must settle promptly after it aborts. */ + signal?: AbortSignal +} + +/** + * One model an endpoint reports about itself. Every field but the id is + * optional because most provider listings disclose an id and nothing else; + * a surface adopting one of these still owes the capacities its adapter needs. + */ +export interface LlmDiscoveredModel { + /** Model id the endpoint accepts. */ + id: string + /** Human-readable name when the endpoint supplies one. */ + name?: string + /** Maximum combined request and response context, when disclosed. */ + contextWindow?: number + /** Maximum output tokens, when disclosed. */ + maxTokens?: number +} + /** One adapter-discovered model; catalog membership is advisory, not request validation. */ export interface LlmModelInfo { /** Provider route that owns this model entry. */ diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index a680447ec7..6b5ecc30d1 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -205,3 +205,55 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toHaveLength(1) }) }) + +describe('model discovery registry', () => { + it('offers one interrogation per settings namespace and disposes with its fiber', async () => { + const ctx = await setup() + const discover = vi.fn(() => Promise.resolve([{ id: 'from-endpoint' }])) + + const dispose = ctx.llm.registerModelDiscovery('llm-example', discover) + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example']) + + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .resolves.toEqual([{ id: 'from-endpoint' }]) + expect(discover).toHaveBeenCalledWith({ baseURL: 'https://gateway.example/v1' }) + + dispose() + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([]) + }) + + it('rejects an unnamed namespace and a second registration of the same one', async () => { + const ctx = await setup() + const discover = (): Promise => Promise.resolve([]) + + expect(() => ctx.llm.registerModelDiscovery('', discover)).toThrow(/non-empty settings namespace/) + ctx.llm.registerModelDiscovery('llm-example', discover) + expect(() => ctx.llm.registerModelDiscovery('llm-example', discover)).toThrow(/already registered/) + expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example']) + }) + + it('normalizes what an interrogation returns without inventing capacities', async () => { + const ctx = await setup() + ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: '' }, + { id: 'keep' }, + { id: 'bare' }, + ] as never)) + + expect(await ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })).toEqual([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: 'bare' }, + ]) + }) + + it('refuses a namespace nothing serves and a draft with no endpoint', async () => { + const ctx = await setup() + ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([])) + + await expect(ctx.llm.discoverModels('llm-absent', { baseURL: 'https://gateway.example/v1' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 30e822910e..4641644ea5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -43,6 +43,8 @@ export const LINK_MAP: Readonly> = { LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', LlmConfigurableProvider: 'core.md', + LlmModelDiscoveryRequest: 'core.md', + LlmDiscoveredModel: 'core.md', ResolvedRetryPolicy: 'llm-streaming.md', Message: 'core.md', MessageSource: 'core.md', From ffd2f188f23b93aaf06ee89dad4407bb89b2bec1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 12:30:41 +0800 Subject: [PATCH 10/27] fix(llm): answer a catalog route's models from pi-ai's own registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "fetch available models" on a built-in provider went to the network. That is the wrong source: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a `GET /models` listing does not disclose. Asking api.deepseek.com what DeepSeek serves is both slower and worse, and against an endpoint that answers a different shape it failed outright. Interrogation is still keyed by settings namespace — the provider being added has no route — but the request may now name the route it is editing. An adapter that already describes that route answers from what it knows, needs no endpoint at all, and never touches the network; only a route the catalog does not describe reaches the wire, and one naming no endpoint is told to set one or enter its models by hand. `ConfigurableProviderView` gained `supportsDiscovery` so a surface offers the action where a namespace can answer instead of hardcoding an adapter family. Three narrower corrections ride along. Discovery no longer claims Azure or Codex: Azure authenticates with an `api-key` header and an `api-version` query despite its OpenAI lineage, and Codex uses OAuth, so both reported an authentication failure as a provider with no models. Cancellation during the body read escaped as the raw abort reason rather than a coded ABORTED. And the schema comment claiming the probe key is never logged overstated it: the host neither stores nor returns it, but it rides the client's outgoing envelope like every other secret-bearing payload, and redacting that tap is a configuration-plane-wide change. --- ...-provider-endpoint-interrogation.i18n.yaml | 2 +- ...4-draft-provider-endpoint-interrogation.md | 2 +- docs/cordis-catalog/services.md | 8 +-- .../client/connection/src/client/fixture.ts | 6 +- .../ui-models/tests/components.spec.tsx | 4 +- .../client/ui-models/tests/readiness.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 10 ++- packages/host/apiproxy/src/api/llm.schema.ts | 12 ++-- packages/host/apiproxy/src/api/llm.ts | 25 +++++-- packages/host/apiproxy/src/api/rpc.schema.ts | 2 +- packages/host/apiproxy/src/api/rpc.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 30 ++++++++- .../apiproxy/tests/client-handler.spec.ts | 1 + packages/llm/llm-pi-ai/src/discovery.ts | 65 ++++++++++++++---- .../llm/llm-pi-ai/tests/discovery.spec.ts | 66 +++++++++++++++++-- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 6 +- packages/llm/llm/src/types.ts | 14 +++- packages/llm/llm/tests/topology.spec.ts | 6 ++ 25 files changed, 217 insertions(+), 64 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 0283d5f3a3..3f4cbcdbb7 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 86b3148626fea90f1d87b80084f7cd4bafeeb1f8 +2026-08-04-draft-provider-endpoint-interrogation.md: a09b971022986442b48bd7aa04a1dcabfa66eb8b 2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 86b3148626..a09b971022 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route: - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. -`dsh-llm-pi-ai` implements it as a plain `GET {baseURL}/models` for OpenAI-compatible protocols only. Their listing shape is the one a gateway, a self-hosted server, and the official endpoints all agree on, which is the case this action exists for. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. +`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. ### Why not pi-ai's own refresh machinery diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index dc0c9fbcdc..f7eff8fde6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -834,9 +834,9 @@ listProviders(): LlmProviderInfo[] * entry, or a provider already declared by any registration throws * `LlmError` without registering the rest. Disposed with the fiber. * @param entries - every configurable provider this plugin owns. - * @returns the disposer that withdraws all of them. + * @returns a handle that withdraws all of them, and can atomically replace them. */ -registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void +registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle /** * List every declared configurable provider, registered or dormant. @@ -938,9 +938,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:234`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 222bd4b125..ceff0575f2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2438,9 +2438,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { llm: { 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: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, supportsDiscovery: true }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, supportsDiscovery: true }, ], }), models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..c6996b322d 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -145,7 +145,7 @@ function scriptedFace(overrides: { llm: { providers: vi.fn(() => Promise.resolve(ok({ providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, { 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: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, @@ -230,7 +230,7 @@ describe('ModelsSection', () => { }) it('decides setup need from the joined credential state and literal-key sidecar', () => { - const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } + const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false } const row = ( credential: ProviderRow['credential'], literalApiKeyConfigured = false, diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index d03cd130f4..c30fb2c773 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -13,7 +13,7 @@ function row(overrides: Partial = {}): ProviderRow { displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], - active: true, + active: true, supportsDiscovery: false, }, configured: true, removable: false, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1e1230935d..6148c9b111 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2123,7 +2123,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelDiscoveryRequest', - declaration: 'export interface LlmModelDiscoveryRequest {\n baseURL: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}', + declaration: 'export interface LlmModelDiscoveryRequest {\n provider?: string;\n baseURL?: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}', }, { name: 'LlmModelInfo', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index dc6f498d56..fce74fc0c8 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: 633c8fe39d989802e8debc350279137b66979593 -README.zh.md: 5e5102840319900f6607acc17288882ccf3c0075 +README.md: 70d0ff258d5ed55678789ef6c7e6c8e64e822db3 +README.zh.md: 3259c03b3d3ca19658d20040024dc40c5c3f287a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index fe48218b3d..dd73fca76d 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,7 +38,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, while `skill.list` resolves the project root from the session header without touching the Agent registry. `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third and last payload a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and is never stored, logged, or echoed. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 72538a6cfb..febda99a17 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`skill.list` 则从会话头解析项目根目录,不触碰 Agent 注册表。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带生成的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储、记录或回显。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 `subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 11295715fe..f0df7c70bb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2563,12 +2563,14 @@ 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 discoverable = new Set(ctx.llm.listModelDiscoveryNamespaces()) const views = directory.map(entry => ({ provider: entry.provider, displayName: entry.displayName, settingsNs: entry.settingsNs, settingsPath: [...entry.settingsPath], active: active.has(entry.provider), + supportsDiscovery: discoverable.has(entry.settingsNs), })) // Routes registered without a directory declaration still appear — // they exist and serve models — just with no settings address. @@ -2580,6 +2582,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro settingsNs: '', settingsPath: [], active: true, + supportsDiscovery: false, }) } return Promise.resolve(ok(request, { providers: views })) @@ -2590,10 +2593,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async discoverModels(request, signal) { - const { settingsNs, baseURL, api, apiKey } = request.payload + const { settingsNs, provider, baseURL, api, apiKey } = request.payload try { const models = await ctx.llm.discoverModels(settingsNs, { - baseURL, + ...provider === undefined ? {} : { provider }, + ...baseURL === undefined ? {} : { baseURL }, ...api === undefined ? {} : { api }, ...apiKey === undefined ? {} : { apiKey }, ...signal === undefined ? {} : { signal }, @@ -2607,7 +2611,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'model-discovery-failed', message: error instanceof Error ? error.message : String(error), - details: { settingsNs, baseURL }, + details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } }, }) } }, diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 44308ec186..d59bb7a78d 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({ settingsNs: z.string(), settingsPath: z.array(z.string()), active: z.boolean(), + supportsDiscovery: z.boolean(), }) satisfies z.ZodType> /** llm.providers request payload. */ @@ -46,11 +47,14 @@ export const discoveredModelViewSchema = z.object({ /** llm.discoverModels request payload. */ export const llmDiscoverModelsRequestSchema = z.object({ settingsNs: z.string().min(1), - baseURL: z.string().min(1), + provider: z.string().min(1).optional(), + baseURL: z.string().min(1).optional(), api: z.string().min(1).optional(), - // Write-only: the host uses it for this one interrogation and never stores, - // logs, or returns it. Kept out of any redacted echo for the same reason - // `credentials.set` never reads a value back. + // Write-only at the host: used for this one interrogation, never stored and + // never returned. It does ride the client's outgoing envelope like every + // other secret-bearing payload (`credentials.set`, `settings.update`), which + // `subscribeEnvelopes()` observers can see — redacting that tap is a + // configuration-plane-wide change, not this method's to make alone. apiKey: z.string().min(1).optional(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 818c5c441c..a070670f97 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -22,6 +22,12 @@ export interface ConfigurableProviderView { settingsPath: string[] /** Whether the route is currently registered (its models are requestable). */ active: boolean + /** + * Whether `llm.discoverModels` can answer for this entry's namespace. A + * surface offers the action only where it works instead of naming an adapter + * family it would have to hardcode. + */ + supportsDiscovery: boolean } /** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */ @@ -46,17 +52,22 @@ export interface LlmApi { * drafting, and return the models it advertises for the user to adopt. * * The payload is the draft, not a stored route: `settingsNs` selects the - * adapter family that knows how to read the listing, and the endpoint, - * protocol, and key come from the form. Nothing is written — the reply is - * candidates, and only a later `settings.mutate` decides what a route - * serves. `apiKey` is therefore accepted here but never stored, logged, or - * echoed back; a provider whose key is already stored omits it and the - * endpoint answers unauthenticated or refuses. + * adapter family that answers, and the rest comes from the form. `provider` + * names the route being edited when there is one — an adapter that already + * describes that route answers from its own registry, with better metadata + * and no network call, and needs no endpoint. A route it does not describe is + * asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for. + * + * Nothing is written — the reply is candidates, and only a later + * `settings.mutate` decides what a route serves. `apiKey` is accepted here + * but never stored or returned; a provider whose key is already stored omits + * it and the endpoint answers unauthenticated or refuses. */ discoverModels( request: RpcRequest<{ settingsNs: string - baseURL: string + provider?: string + baseURL?: string api?: string apiKey?: string }>, diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 90972ee78b..2733c6e940 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -55,7 +55,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), - z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string() }) }), + z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index c0e2f27c96..df1de7616b 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -78,7 +78,7 @@ export interface RpcErrorDetailsMap { * it is what the form shows before falling back to hand-entry — and the * details name the endpoint asked, never the credential offered. */ - 'model-discovery-failed': { settingsNs: string; baseURL: string } + 'model-discovery-failed': { settingsNs: string; baseURL?: string } 'title-invalid': { sessionId: SessionId } 'fork-unavailable': { sessionId: SessionId } 'subagent-parent-unavailable': { parentSessionId: SessionId } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 8be2009cca..8136a2bd0c 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -523,12 +523,17 @@ describe('llm domain', () => { ]) ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash'])) ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1'])) + // Only one namespace can answer an interrogation, so the flag follows the + // entry's namespace rather than being assumed for every row. + ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([])) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.llm.providers(request({}))) expect(value.providers).toEqual([ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false }, - { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true }, + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false, supportsDiscovery: true }, + // An undeclared live route has no settings address, so nothing can be + // interrogated on its behalf either. + { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true, supportsDiscovery: false }, ]) }) @@ -596,6 +601,25 @@ describe('llm.discoverModels', () => { .not.toContain('llm-pi-ai') }) + it('carries the route being edited so an adapter can answer from its own registry', async () => { + const ctx = await harness() + let probe: unknown + ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { + probe = request_ + return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + const value = expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + provider: 'deepseek', + }))) + + // No endpoint at all: a route the adapter already describes needs none. + expect(probe).toEqual({ provider: 'deepseek' }) + expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) + }) + it('omits a credential and protocol the draft does not name', async () => { const ctx = await harness() let probe: unknown diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..1e3daacd3e 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -677,6 +677,7 @@ describe('config unary surface', () => { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false, + supportsDiscovery: true, } const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } const api = scriptedApi({ diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index fb56e5645b..a6c71110a2 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -1,12 +1,17 @@ /** - * One-shot interrogation of a provider endpoint's model listing, serving the - * configuration surface's "fetch available models" action. + * Answering "which models can this provider serve?" for the configuration + * surface's "fetch available models" action. * - * This is deliberately *not* a catalog refresh. Nothing here is stored: the - * request carries a draft the user is still editing — an endpoint and a - * credential neither of which may exist in `settings.yaml` yet — and the reply - * is candidate metadata the surface offers for adoption. `settings.yaml` - * remains the only thing that decides what a route serves. + * A route the installed pi-ai catalog ships is answered **from that catalog**, + * with no network call at all: pi-ai's registry is the authoritative list for + * its own providers, and it carries the capacities a listing endpoint would + * not disclose. Only a route the catalog does not describe — a gateway, a + * self-hosted server — is interrogated over the wire. + * + * Neither path is a catalog refresh. Nothing here is stored: the request + * carries a draft the user is still editing, and the reply is candidate + * metadata the surface offers for adoption. `settings.yaml` remains the only + * thing that decides what a route serves. * * Only OpenAI-compatible protocols are interrogated. Their listing is the one * shape a gateway, a self-hosted server, and the official endpoints all agree @@ -20,16 +25,17 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' +import { catalogModels } from './catalog.ts' /** - * Protocols whose model listing this module can read. Every entry speaks - * OpenAI's `GET /models` shape; pi-ai's other protocols are absent because a - * wrong guess at their response shape would be reported as an empty provider - * rather than as the gap it is. + * Protocols whose model listing this module can read: the two that speak + * OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its + * OpenAI lineage — it authenticates with an `api-key` header and requires an + * `api-version` query — and Codex authenticates through OAuth; guessing at + * either would report an authentication failure as a provider with no models. + * pi-ai's remaining protocols are absent for the same reason. */ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ - 'azure-openai-responses', - 'openai-codex-responses', 'openai-completions', 'openai-responses', ]) @@ -165,6 +171,26 @@ function readListing(body: unknown): LlmDiscoveredModel[] { export async function discoverModels( request: LlmModelDiscoveryRequest, ): Promise { + // A catalog route already has its answer, and a better one: the installed + // entries carry context windows and output caps no listing endpoint reports. + if (request.provider !== undefined) { + const installed = catalogModels(request.provider) + if (installed.size > 0) { + return [...installed.values()].map(model => ({ + id: model.id, + name: model.name, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })) + } + } + if (request.baseURL === undefined || request.baseURL.length === 0) { + throw new LlmError( + `pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its` + + " endpoint; set a baseURL, or enter this provider's models by hand", + 'DISCOVERY_FAILED', + ) + } const api = request.api ?? 'openai-completions' if (!LISTABLE_PROTOCOLS.has(api)) { throw new LlmError( @@ -196,7 +222,18 @@ export async function discoverModels( 'DISCOVERY_FAILED', ) } - const text = await readBounded(response, url) + let text: string + try { + text = await readBounded(response, url) + } catch (error: unknown) { + // Cancellation during the body read rejects with the abort reason, which + // may be any value; the caller gets the same coded failure it would have + // for a cancellation before the request went out. + if (request.signal?.aborted) { + throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error }) + } + throw error + } let body: unknown try { body = JSON.parse(text) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index c590ad44e1..3639a38ead 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { discoverModels } from '../src/discovery.ts' const servers: Server[] = [] @@ -25,6 +27,7 @@ async function listingServer(behavior: { status?: number body?: string chunks?: string[] + holdOpenMs?: number }): Promise { const paths: string[] = [] const headers: IncomingMessage['headers'][] = [] @@ -35,7 +38,10 @@ async function listingServer(behavior: { // No declared length: the ceiling has to hold on what is read. response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' }) for (const chunk of behavior.chunks) response.write(chunk) - response.end() + if (behavior.holdOpenMs === undefined) { response.end(); return } + // Left open so a caller's cancellation lands while the body is still + // being read rather than after it completed. + setTimeout(() => { response.end() }, behavior.holdOpenMs) return } const body = behavior.body ?? '{}' @@ -60,6 +66,39 @@ async function harness(): Promise { return ctx } +describe('catalog-route model discovery', () => { + it('answers from the installed registry, with capacities and no network call', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek', baseURL: server.url }) + + // pi-ai's own registry is the authority for its own providers, and it + // carries what a listing endpoint would not disclose. + expect(models.map(model => model.id).sort()) + .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort()) + expect(models.every(model => (model.contextWindow ?? 0) > 0 && (model.maxTokens ?? 0) > 0)).toBe(true) + expect(server.paths).toEqual([]) + }) + + it('needs no endpoint for a route the catalog describes', async () => { + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0) + }) + + it('says where a route the catalog does not describe must get its models', async () => { + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway' })) + .rejects.toThrow(/ships no catalog for provider "acme-gateway".*set a baseURL/s) + // A form that cleared the field says the same thing as one that never had it. + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: '' })) + .rejects.toThrow(/set a baseURL/) + // The seam refuses a request naming neither, so the module's own guard for + // that shape is only reachable by calling it directly. + await expect(discoverModels({})).rejects.toThrow(/set a baseURL/) + }) +}) + describe('draft-provider model discovery', () => { it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => { const server = await listingServer({ @@ -171,12 +210,27 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) }) - it('says which protocols it cannot interrogate rather than guessing a shape', async () => { + it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( + 'says it cannot interrogate %s rather than guessing a shape', + async (api) => { + // Azure authenticates with an `api-key` header and an `api-version` + // query despite its OpenAI lineage, and Codex uses OAuth; guessing at + // either would report an auth failure as a provider with no models. + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://gateway.example/v1', api })) + .rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' }) + }, + ) + + it('reports cancellation during the body read as an abort, not a raw reason', async () => { const ctx = await harness() - await expect(ctx.llm.discoverModels('llm-pi-ai', { - baseURL: 'https://gateway.example/v1', - api: 'anthropic-messages', - })).rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' }) + const controller = new AbortController() + // Chunked, so the headers arrive and the cancellation lands mid-body. + const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 }) + const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal }) + setTimeout(() => { controller.abort('test cancellation') }, 40) + + await expect(probe).rejects.toMatchObject({ code: 'ABORTED' }) }) it('honors caller cancellation', async () => { diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 5f7787cbd8..0b7ea01315 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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: 60cc94b6375030955136b4efaf969b69bca2530a -README.zh.md: 5b24a1e311c37d13dc4f287e5ae4b57efe00e4e6 +README.md: 3a7ec1e8daa33d825fadc15e6481781da48571c4 +README.zh.md: d5a60a574a7947de83c44df85ce71e16b54be9f4 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 500f0092c5..3a7ec1e8da 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -26,7 +26,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. -Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and an empty namespace or endpoint fails with `INVALID_DISCOVERY`. +Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index e6cf9742de..524754c9cf 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -26,7 +26,7 @@ `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。 -询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,空 namespace 或空端点以 `INVALID_DISCOVERY` 失败。 +询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f57ad46eec..029c19bae8 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -517,8 +517,10 @@ export class LlmService extends Service { if (discover === undefined) { throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY') } - if (request.baseURL.length === 0) { - throw new LlmError('model discovery needs a non-empty baseURL', 'INVALID_DISCOVERY') + // One of the two identifies what to describe: a route the adapter knows, or + // an endpoint to ask. Neither leaves nothing to answer about. + if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) { + throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY') } const discovered = await discover(request) const seen = new Set() diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 220016cff0..63314135a0 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -146,8 +146,18 @@ export interface LlmConfigurableProvider { * route: a provider being added has no route to name. */ export interface LlmModelDiscoveryRequest { - /** Endpoint to interrogate. */ - baseURL: string + /** + * Route the draft is editing, when it edits an existing one. A route whose + * adapter already knows its models answers from that knowledge instead of + * asking the endpoint — the adapter's own registry is the better answer, and + * it costs no network call. + */ + provider?: string + /** + * Endpoint to interrogate. Optional because a route the adapter already + * describes needs none; a route it does not must supply one. + */ + baseURL?: string /** Wire protocol the endpoint speaks, when the draft names one. */ api?: string /** Credential for this interrogation alone; the harness never stores it. */ diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 6b5ecc30d1..b0b959ddf0 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -255,5 +255,11 @@ describe('model discovery registry', () => { .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' })) .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', { provider: '', baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', {})) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + // Naming a route alone is enough: the adapter may know it without an endpoint. + await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([]) }) }) From 2dd4b8e78dee3a89fe04396935acccee960e0c36 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:08:07 +0800 Subject: [PATCH 11/27] fix(host): pin model discovery to loopback and drop its unread wire field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llm.discoverModels was reachable from any declared trusted host. The method takes a caller-supplied baseURL and makes the host issue a GET to it, then reports the status or the parsed body — so on a LAN deployment an anonymous caller had a probe for whatever the host can reach and the browser cannot, plus a path that carries a draft credential. The PRIVILEGED_METHODS doc already states the rule this broke: trustedHosts is a DNS-rebinding fence, not authentication, so the configuration plane stays loopback-same-origin. It is in that set now, asserted both against the hand-built fence and over real HTTP beside the catalog reads that deliberately stay reachable. supportsDiscovery and listModelDiscoveryNamespaces are gone. The field was required on the wire and read by nobody: its own contract said a surface should offer the action "instead of naming an adapter family it would have to hardcode", while the surface hardcodes llm-pi-ai in two places and gates the button on whether there is anything to probe. Its shape did not fit the second caller either — the create card has no row to read a per-row field from. Keeping a required field alive for a consumer that may never arrive costs every producer and fixture a value nobody consults, which is exactly how the fixtures drifted. The registry that fed it had no other production consumer, so registration and disposal are now observed through the offer itself. The Agent Note claimed the key is never logged, which the wire schema beside it already contradicts, and predated both the provider field and the catalog-answer path. The two new public types pointed at core.md without a type-equiv block or manifest entry, so the generated service catalog named documentation that did not exist. --- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 6 +-- ...raft-provider-endpoint-interrogation.zh.md | 6 +-- docs/cordis-catalog/services.md | 7 --- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 49 +++++++++++++++++++ docs/core-data-structures/core.zh.md | 49 +++++++++++++++++++ .../client/connection/src/client/fixture.ts | 6 +-- packages/client/connection/src/index.ts | 14 ++++-- .../client/connection/tests/node-half.spec.ts | 7 ++- .../ui-models/tests/components.spec.tsx | 4 +- .../client/ui-models/tests/readiness.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 -- packages/host/apiproxy/src/api-proxy.ts | 3 -- packages/host/apiproxy/src/api/llm.schema.ts | 1 - packages/host/apiproxy/src/api/llm.ts | 6 --- .../apiproxy/tests/api-proxy-config.spec.ts | 6 +-- .../apiproxy/tests/client-handler.spec.ts | 1 - packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 10 ++++ packages/llm/llm-pi-ai/README.zh.md | 10 ++++ packages/llm/llm-pi-ai/src/discovery.ts | 6 +++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 7 +-- packages/llm/llm/src/index.ts | 9 ---- packages/llm/llm/tests/topology.spec.ts | 11 +++-- scripts/type-equiv.manifest.json | 10 ++++ 26 files changed, 182 insertions(+), 64 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 3f4cbcdbb7..c30a1e39e2 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: a09b971022986442b48bd7aa04a1dcabfa66eb8b -2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a +2026-08-04-draft-provider-endpoint-interrogation.md: 49b863a3b923e9cdae34462c63fb2e2ed0968941 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: a6a74407b0713ffcadc734ecbca2b7d7363d7361 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index a09b971022..49b863a3b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -16,10 +16,10 @@ The awkward part is that the question is about something that does not exist yet Interrogation is keyed by **settings namespace**, not by provider route: -- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns; `ctx.llm.listModelDiscoveryNamespaces()` lets a surface offer the action only where it works; `ctx.llm.discoverModels(settingsNs, request)` asks. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. -- `LlmModelDiscoveryRequest` carries the draft — `baseURL`, an optional `api`, an optional `apiKey`, and a signal. Nothing in this path reads or writes settings or credentials; the caller owns both. +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. +- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path reads or writes settings or credentials; the caller owns both. - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. -- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. +- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. `dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index 0f6a63385d..a6a74407b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -16,10 +16,10 @@ Status: implemented 询问以 **settings namespace** 为键,而不是提供方路由: -- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力;`ctx.llm.listModelDiscoveryNamespaces()` 让界面只在可用之处提供该动作;`ctx.llm.discoverModels(settingsNs, request)` 发起询问。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 -- `LlmModelDiscoveryRequest` 携带草稿——`baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal。这条路径既不读也不写 settings 与 credentials;两者都归调用方所有。 +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 +- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径既不读也不写 settings 与 credentials;两者都归调用方所有。 - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 -- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储、记录或回显。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 +- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外它被钉在回环还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 `dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f7eff8fde6..b353d51965 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -856,13 +856,6 @@ listConfigurableProviders(): LlmConfigurableProvider[] */ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void -/** - * List the settings namespaces that can interrogate a provider endpoint, so - * a surface can offer the action only where it will work. - * @returns the namespaces in registration order. - */ -listModelDiscoveryNamespaces(): string[] - /** * Interrogate one provider endpoint for the models it advertises. The * request describes a draft, not a stored route, so nothing here reads or diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 415d74e6f6..219017a523 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 docs/core-data-structures/core.md -core.md: 1b5704384157688b45ae0900bf2d9924426bbd6b -core.zh.md: 05802039920163ac8185703ab483c5603087ed96 +core.md: 97567226e25f06a7d97fe015995db11d94be7397 +core.zh.md: 8d06fa481a5a1e920f4e450f44dc26c28a1121a6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b57043841..97567226e2 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -259,6 +259,55 @@ interface LlmModelInfo { } ``` +A provider a surface is still drafting has no route and no catalog, so interrogation is described separately: the request carries the draft the user is editing, and the reply is candidates a surface may adopt rather than a catalog it must serve. + +```ts type-equiv +/** + * One interrogation of a provider endpoint that configuration has not stored + * yet. Configuration surfaces send the draft a user is still editing, so the + * request carries the endpoint and credential directly instead of naming a + * route: a provider being added has no route to name. + */ +interface LlmModelDiscoveryRequest { + /** + * Route the draft is editing, when it edits an existing one. A route whose + * adapter already knows its models answers from that knowledge instead of + * asking the endpoint — the adapter's own registry is the better answer, and + * it costs no network call. + */ + provider?: string + /** + * Endpoint to interrogate. Optional because a route the adapter already + * describes needs none; a route it does not must supply one. + */ + baseURL?: string + /** Wire protocol the endpoint speaks, when the draft names one. */ + api?: string + /** Credential for this interrogation alone; the harness never stores it. */ + apiKey?: string + /** Caller cancellation; implementations must settle promptly after it aborts. */ + signal?: AbortSignal +} +``` + +```ts type-equiv +/** + * One model an endpoint reports about itself. Every field but the id is + * optional because most provider listings disclose an id and nothing else; + * a surface adopting one of these still owes the capacities its adapter needs. + */ +interface LlmDiscoveredModel { + /** Model id the endpoint accepts. */ + id: string + /** Human-readable name when the endpoint supplies one. */ + name?: string + /** Maximum combined request and response context, when disclosed. */ + contextWindow?: number + /** Maximum output tokens, when disclosed. */ + maxTokens?: number +} +``` + Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity, adapter call defaults, and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution. ```ts type-equiv diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 0580203992..8d06fa481a 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -265,6 +265,55 @@ interface LlmModelInfo { } ``` +界面正在起草的提供方既没有路由也没有 catalog,因此询问被单独描述:请求携带用户正在编辑的草稿,回复是界面可以采纳的候选,而不是它必须服务的 catalog。 + +```ts type-equiv +/** + * One interrogation of a provider endpoint that configuration has not stored + * yet. Configuration surfaces send the draft a user is still editing, so the + * request carries the endpoint and credential directly instead of naming a + * route: a provider being added has no route to name. + */ +interface LlmModelDiscoveryRequest { + /** + * Route the draft is editing, when it edits an existing one. A route whose + * adapter already knows its models answers from that knowledge instead of + * asking the endpoint — the adapter's own registry is the better answer, and + * it costs no network call. + */ + provider?: string + /** + * Endpoint to interrogate. Optional because a route the adapter already + * describes needs none; a route it does not must supply one. + */ + baseURL?: string + /** Wire protocol the endpoint speaks, when the draft names one. */ + api?: string + /** Credential for this interrogation alone; the harness never stores it. */ + apiKey?: string + /** Caller cancellation; implementations must settle promptly after it aborts. */ + signal?: AbortSignal +} +``` + +```ts type-equiv +/** + * One model an endpoint reports about itself. Every field but the id is + * optional because most provider listings disclose an id and nothing else; + * a surface adopting one of these still owes the capacities its adapter needs. + */ +interface LlmDiscoveredModel { + /** Model id the endpoint accepts. */ + id: string + /** Human-readable name when the endpoint supplies one. */ + name?: string + /** Maximum combined request and response context, when disclosed. */ + contextWindow?: number + /** Maximum output tokens, when disclosed. */ + maxTokens?: number +} +``` + 对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。 ```ts type-equiv diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ceff0575f2..222bd4b125 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2438,9 +2438,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { llm: { providers: request => ok(request, { providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, supportsDiscovery: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, supportsDiscovery: true }, + { 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 }, ], }), models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 888675e965..2e27a78d70 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -44,10 +44,15 @@ export const Config: z = 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', ]) /** diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index d3ac13716e..3015881d2f 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -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]) } diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index c6996b322d..aa9082e7dd 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -145,7 +145,7 @@ function scriptedFace(overrides: { llm: { providers: vi.fn(() => Promise.resolve(ok({ providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, + { 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: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, @@ -230,7 +230,7 @@ describe('ModelsSection', () => { }) it('decides setup need from the joined credential state and literal-key sidecar', () => { - const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false } + const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } const row = ( credential: ProviderRow['credential'], literalApiKeyConfigured = false, diff --git a/packages/client/ui-models/tests/readiness.spec.ts b/packages/client/ui-models/tests/readiness.spec.ts index c30fb2c773..d03cd130f4 100644 --- a/packages/client/ui-models/tests/readiness.spec.ts +++ b/packages/client/ui-models/tests/readiness.spec.ts @@ -13,7 +13,7 @@ function row(overrides: Partial = {}): ProviderRow { displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], - active: true, supportsDiscovery: false, + active: true, }, configured: true, removable: false, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6148c9b111..794c22e2b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -432,10 +432,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void', jsDoc: '/**\n * Offer to interrogate provider endpoints on behalf of the settings\n * namespace this plugin owns. The namespace is the key because that is what\n * a configuration surface already holds from the configurable-provider\n * directory, and because a provider being *added* has no route to name yet.\n * Disposed with the fiber.\n * @param settingsNs - the namespace whose profiles this discovery serves.\n * @param discover - interrogates one endpoint; must honor `request.signal`.\n * @returns the disposer that withdraws the offer.\n */', }, - { - signature: 'listModelDiscoveryNamespaces(): string[]', - jsDoc: '/**\n * List the settings namespaces that can interrogate a provider endpoint, so\n * a surface can offer the action only where it will work.\n * @returns the namespaces in registration order.\n */', - }, { signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise', jsDoc: '/**\n * Interrogate one provider endpoint for the models it advertises. The\n * request describes a draft, not a stored route, so nothing here reads or\n * writes settings or credentials — the caller owns both, and the reply is\n * candidate metadata a surface may offer for adoption.\n * @param settingsNs - namespace whose registered discovery serves this draft.\n * @param request - the endpoint, protocol, and one-shot credential to use.\n * @returns the advertised models, deduplicated in endpoint order.\n */', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f0df7c70bb..f41f23839d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2563,14 +2563,12 @@ 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 discoverable = new Set(ctx.llm.listModelDiscoveryNamespaces()) const views = directory.map(entry => ({ provider: entry.provider, displayName: entry.displayName, settingsNs: entry.settingsNs, settingsPath: [...entry.settingsPath], active: active.has(entry.provider), - supportsDiscovery: discoverable.has(entry.settingsNs), })) // Routes registered without a directory declaration still appear — // they exist and serve models — just with no settings address. @@ -2582,7 +2580,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro settingsNs: '', settingsPath: [], active: true, - supportsDiscovery: false, }) } return Promise.resolve(ok(request, { providers: views })) diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index d59bb7a78d..6ded8c32ac 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -16,7 +16,6 @@ export const configurableProviderViewSchema = z.object({ settingsNs: z.string(), settingsPath: z.array(z.string()), active: z.boolean(), - supportsDiscovery: z.boolean(), }) satisfies z.ZodType> /** llm.providers request payload. */ diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index a070670f97..edd85a52b2 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -22,12 +22,6 @@ export interface ConfigurableProviderView { settingsPath: string[] /** Whether the route is currently registered (its models are requestable). */ active: boolean - /** - * Whether `llm.discoverModels` can answer for this entry's namespace. A - * surface offers the action only where it works instead of naming an adapter - * family it would have to hardcode. - */ - supportsDiscovery: boolean } /** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 8136a2bd0c..54235c0218 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -529,11 +529,11 @@ describe('llm domain', () => { const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.llm.providers(request({}))) expect(value.providers).toEqual([ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false, supportsDiscovery: true }, + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false }, // An undeclared live route has no settings address, so nothing can be // interrogated on its behalf either. - { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true, supportsDiscovery: false }, + { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true }, ]) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1e3daacd3e..490e0ad7f1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -677,7 +677,6 @@ describe('config unary surface', () => { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false, - supportsDiscovery: true, } const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } const api = scriptedApi({ diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 3420b9d493..077dc646ec 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: 07de1c0aceeccff5f3f14a43c4888b4481c0293a -README.zh.md: 0e8895a0192c7f18e2b6ee8869896080f7ff49e2 +README.md: 6f3fa0bb0eb0236ab885ef803ace8069b1d52302 +README.zh.md: c0897d92da83084c5eeac02d5fd24c34a71e1af2 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 07de1c0ace..7cb575c9ed 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -79,6 +79,16 @@ Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseU The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. +## Endpoint interrogation + +The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves. + +A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. + +Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. + +Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. + ## Provider/model routing and replay Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0e8895a019..a99d70aa7d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -79,6 +79,16 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 +## 端点询问 + +插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。 + +点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 + +询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 + +多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 + ## 提供方/模型路由与回放 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index a6c71110a2..58c58c9aab 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -191,6 +191,12 @@ export async function discoverModels( 'DISCOVERY_FAILED', ) } + // A draft that has not chosen a protocol yet is asked as OpenAI Chat + // Completions: it is the shape a gateway is overwhelmingly likely to speak, + // and the alternative — refusing until the field is filled — would withhold + // the action from the case it exists for. The cost is a misdirected message + // when the endpoint speaks something else (an Anthropic gateway answers 401, + // which reads as a credential problem), and hand-entry remains the way out. const api = request.api ?? 'openai-completions' if (!LISTABLE_PROTOCOLS.has(api)) { throw new LlmError( diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 3639a38ead..bda81776c7 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -245,7 +245,7 @@ describe('draft-provider model discovery', () => { it('is offered for the namespace, and refuses one it does not serve', async () => { const ctx = await harness() - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai']) + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0) await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' })) .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' })) @@ -256,10 +256,11 @@ describe('draft-provider model discovery', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, {}) - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai']) + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0) await fiber.dispose() - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([]) + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) }) }) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 029c19bae8..d330bd2350 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -491,15 +491,6 @@ export class LlmService extends Service { return () => void dispose() } - /** - * List the settings namespaces that can interrogate a provider endpoint, so - * a surface can offer the action only where it will work. - * @returns the namespaces in registration order. - */ - listModelDiscoveryNamespaces(): string[] { - return [...this.discoveries.keys()] - } - /** * Interrogate one provider endpoint for the models it advertises. The * request describes a draft, not a stored route, so nothing here reads or diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index b0b959ddf0..8577e14b7c 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -212,14 +212,15 @@ describe('model discovery registry', () => { const discover = vi.fn(() => Promise.resolve([{ id: 'from-endpoint' }])) const dispose = ctx.llm.registerModelDiscovery('llm-example', discover) - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example']) - await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) .resolves.toEqual([{ id: 'from-endpoint' }]) expect(discover).toHaveBeenCalledWith({ baseURL: 'https://gateway.example/v1' }) + // Disposal is observed through the offer itself, which is the only thing + // the registration ever produced. dispose() - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([]) + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .rejects.toThrow(/no model discovery is registered/) }) it('rejects an unnamed namespace and a second registration of the same one', async () => { @@ -229,7 +230,9 @@ describe('model discovery registry', () => { expect(() => ctx.llm.registerModelDiscovery('', discover)).toThrow(/non-empty settings namespace/) ctx.llm.registerModelDiscovery('llm-example', discover) expect(() => ctx.llm.registerModelDiscovery('llm-example', discover)).toThrow(/already registered/) - expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-example']) + // The refused second registration left the first one serving. + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .resolves.toEqual([]) }) it('normalizes what an interrogation returns without inventing capacities', async () => { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f8e56eab8d..ef94c0fe85 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,6 +46,16 @@ "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmModelDiscoveryRequest", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmDiscoveredModel", + "source": "packages/llm/llm/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelContext", From b2d0e8972fb6b8c2cb7014980e66e4dd0caefd4f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 13:16:09 +0800 Subject: [PATCH 12/27] docs(host): re-record the pairings master's wording moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master polished two Chinese sentences this branch also edits — an expectation is now 陈旧 rather than 过期, and the package-root sentence spells out 包(package). Taking master's wording alongside this branch's own additions leaves the recorded pair fingerprints stale, so they are re-recorded against the merged text. --- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm/README.i18n.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index fce74fc0c8..26f296b2bc 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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: 70d0ff258d5ed55678789ef6c7e6c8e64e822db3 -README.zh.md: 3259c03b3d3ca19658d20040024dc40c5c3f287a +README.md: dd73fca76d60a27a8f2e764cfec6c64612ad41a9 +README.zh.md: febda99a17beeaef49d5af410afd58bc8e9481c6 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 077dc646ec..1b7bd4ec8b 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: 6f3fa0bb0eb0236ab885ef803ace8069b1d52302 -README.zh.md: c0897d92da83084c5eeac02d5fd24c34a71e1af2 +README.md: 7cb575c9ed85a21f8dab7b37d2e76cf74fe5d16f +README.zh.md: a99d70aa7dd157f18332a1fa0e283fc01dd23a5d diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 0b7ea01315..ffd80072a1 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -3,4 +3,4 @@ # 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: 3a7ec1e8daa33d825fadc15e6481781da48571c4 -README.zh.md: d5a60a574a7947de83c44df85ce71e16b54be9f4 +README.zh.md: 524754c9cf4c7df3549fcf721836c7b755874d3d From 66c2cb81d3b6d3c5fac32dcd8032379aaef013b7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 20:55:39 +0800 Subject: [PATCH 13/27] fix(llm): let an interrogation use the credential its route already stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A configuration surface never holds a stored secret — it edits a redacted descriptor — so once a key is saved, the draft it sends carries the route and the endpoint and no credential at all. The interrogation went out unauthenticated and the endpoint's 401 came back as "check the API key", pointing at the one thing that was fine. A named route now supplies its own credential, resolved exactly as a request to it would be. A key typed into the form still wins: it is the one under test, and may be the replacement for the stored one that is failing. Resolution is a callback the probe invokes past the catalog short-circuit and the protocol check, so a route answered from the installed registry costs no credential lookup — and cannot fail over a credential the question never needed. --- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 8 ++-- ...raft-provider-endpoint-interrogation.zh.md | 8 ++-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/discovery.ts | 14 +++++- packages/llm/llm-pi-ai/src/index.ts | 20 +++++++- .../llm/llm-pi-ai/tests/discovery.spec.ts | 47 +++++++++++++++++++ 9 files changed, 94 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index c30a1e39e2..ae96598b48 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 49b863a3b923e9cdae34462c63fb2e2ed0968941 -2026-08-04-draft-provider-endpoint-interrogation.zh.md: a6a74407b0713ffcadc734ecbca2b7d7363d7361 +2026-08-04-draft-provider-endpoint-interrogation.md: 65545098cd1063c40081481c1ac8f0afdb4fb390 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: cb09042904f4ab1558c0c214d275a934234955ac diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 49b863a3b9..65545098cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -17,7 +17,7 @@ The awkward part is that the question is about something that does not exist yet Interrogation is keyed by **settings namespace**, not by provider route: - `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. -- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path reads or writes settings or credentials; the caller owns both. +- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test. - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. @@ -25,7 +25,7 @@ Interrogation is keyed by **settings namespace**, not by provider route: ### Why not pi-ai's own refresh machinery -pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. +pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. The route's stored credential is resolved by the plugin's own per-request resolver, and only on the branch that reaches the network, so a catalog route answers without touching credentials and never fails over one the question did not need. ## Alternatives considered @@ -33,7 +33,7 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a ` **Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve. -**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical. +**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical — with the credential as the one exception, because it is the one field a surface is never shown and so can never put in the draft. **Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback. @@ -47,4 +47,4 @@ What it costs: the wire gained a third secret-carrying payload, so the configura ## Testing -`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index a6a74407b0..cb09042904 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -17,7 +17,7 @@ Status: implemented 询问以 **settings namespace** 为键,而不是提供方路由: - `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 -- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径既不读也不写 settings 与 credentials;两者都归调用方所有。 +- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。 - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 - `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外它被钉在回环还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 @@ -25,7 +25,7 @@ Status: implemented ### 为什么不用 pi-ai 自己的 refresh 机制 -pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。 +pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。路由已存的凭据由本插件自己那套逐请求解析器取出,且只在真正要联网的那条分支上进行,因此 catalog 路由作答时既不触碰凭据,也不会因为一把这次询问根本用不上的密钥而失败。 ## Alternatives considered @@ -33,7 +33,7 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 **把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。 -**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致。 +**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致——凭据是唯一的例外,因为它是界面从不被展示、因而永远无法放进草稿的那个字段。 **询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 @@ -47,4 +47,4 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 ## Testing -`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1b7bd4ec8b..b4e9cffabb 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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: 7cb575c9ed85a21f8dab7b37d2e76cf74fe5d16f -README.zh.md: a99d70aa7dd157f18332a1fa0e283fc01dd23a5d +README.md: af0e952dd8dbd9767b98229ee6b87262007d6738 +README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7cb575c9ed..af0e952dd8 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -85,6 +85,8 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. + Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a99d70aa7d..f8a19999f0 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -85,6 +85,8 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。 + 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index 58c58c9aab..bff2c9a7ca 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -164,12 +164,18 @@ function readListing(body: unknown): LlmDiscoveredModel[] { /** * Interrogate one draft provider endpoint for the models it advertises. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param storedApiKey - the credential the named route already stored, asked + * for only when the draft carries none and only on the path that reaches the + * network. A configuration surface never holds a stored secret — it edits a + * redacted descriptor — so without this an already-configured route would be + * interrogated unauthenticated and answer 401. * @returns the advertised models in endpoint order. * @throws LlmError when the protocol has no readable listing, the endpoint * refuses or fails the request, or the reply is not a model listing. */ export async function discoverModels( request: LlmModelDiscoveryRequest, + storedApiKey?: () => Promise, ): Promise { // A catalog route already has its answer, and a better one: the installed // entries carry context windows and output caps no listing endpoint reports. @@ -205,13 +211,19 @@ export async function discoverModels( ) } const url = listingUrl(request.baseURL) + // A key typed into the form wins: it is the one the user is testing, and it + // may be the replacement for exactly the stored key that is failing. The + // stored one is only asked for here, past the catalog short-circuit and the + // protocol check, so a route answered from the registry costs no credential + // lookup — and no diagnostic about a credential it never needed. + const apiKey = request.apiKey ?? await storedApiKey?.() let response: Response try { response = await fetch(url, { method: 'GET', headers: { accept: 'application/json', - ...request.apiKey === undefined ? {} : { authorization: `Bearer ${request.apiKey}` }, + ...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }, ...attributionHeaders(), }, ...request.signal === undefined ? {} : { signal: request.signal }, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index aac3ff5a62..0d058e94ac 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -177,10 +177,26 @@ export function apply(ctx: Context, config: Config): void { directoryFacts = entries } ensureDirectory() + /** + * The credential a named route already resolves, for an interrogation whose + * draft carries none. A route being declared for the first time names no + * profile yet, and a profile that names no credential defers to pi-ai's own + * discovery, so both answer `undefined` and the endpoint is asked + * unauthenticated — the same posture a request to that route would take. + */ + const storedApiKey = async (provider: string | undefined): Promise => { + if (provider === undefined) return undefined + const profile = profiles().get(provider) + if (profile === undefined) return undefined + return resolveApiKey(provider, profile) + } // Interrogating an endpoint is a configuration-time action over a draft, so // it is offered for the whole namespace rather than per route: the provider - // a surface is adding does not exist yet. - ctx.llm.registerModelDiscovery(NS, discoverModels) + // a surface is adding does not exist yet. The draft is the whole request + // except the credential: a configuration surface edits a redacted descriptor + // and never holds a stored secret, so an already-configured route supplies + // its own here rather than being interrogated unauthenticated. + ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider))) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index bda81776c7..916700fbbf 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -8,8 +8,11 @@ import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { discoverModels } from '../src/discovery.ts' const servers: Server[] = [] +/** Credential variables a test set, cleared so the next one starts unset. */ +const touchedEnv: string[] = [] afterEach(async () => { + for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name) await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) @@ -140,6 +143,50 @@ describe('draft-provider model discovery', () => { expect(server.headers[0]?.authorization).toBeUndefined() }) + it('authenticates a configured route the draft cannot supply a key for', async () => { + // What the Models page actually sends after a key is saved: the form holds + // the redacted descriptor, so the draft names the route and the endpoint + // and no credential at all. Interrogating unauthenticated would answer 401 + // and read as a wrong key. + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = new Context() + await ctx.plugin(LlmService) + process.env['ACME_GATEWAY_KEY'] = 'stored-key' + touchedEnv.push('ACME_GATEWAY_KEY') + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'ACME_GATEWAY_KEY', + api: 'openai-completions', + baseURL: server.url, + models: [{ id: 'acme-large' }], + }, + }, + }) + + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url }) + // A key typed into the form is the one being tested — possibly the + // replacement for the stored one — so it wins. + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url, apiKey: 'typed' }) + // A route no profile declares yet is the create case: nothing is stored. + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'not-declared-yet', baseURL: server.url }) + + expect(server.headers.map(headers => headers.authorization)) + .toEqual(['Bearer stored-key', 'Bearer typed', undefined]) + }) + + it('leaves a catalog route\'s credential unresolved, having never reached the network', async () => { + // The catalog answers before any endpoint is asked, so a route whose + // profile names a credential that is not set must still answer rather than + // failing over a key the interrogation never needed. + const ctx = new Context() + await ctx.plugin(LlmService) + Reflect.deleteProperty(process.env, 'ABSENT_FOR_DISCOVERY') + await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'ABSENT_FOR_DISCOVERY' } } }) + + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0) + }) + it('drops unusable rows rather than failing the whole listing', async () => { const server = await listingServer({ body: JSON.stringify({ From 1b5f06ed0b50b3ca6a768c97ea727dc6f12bdd8c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:17:17 +0800 Subject: [PATCH 14/27] fix(web): render CJK-adjacent strong emphasis --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- THIRD_PARTY_NOTICES.md | 2 + apps/web/tests/markdown-cjk-strong.e2e.ts | 128 ++++++++++++++++++ .../markdown-cjk-strong/ui.expected.md | 52 +++++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/package.json | 2 + .../src/markdown/MarkdownText.tsx | 4 +- .../src/markdown/remarkCjkFriendlyStrong.ts | 88 ++++++++++++ .../ui-primitives/tests/markdown.spec.tsx | 65 +++++++++ pnpm-lock.yaml | 6 + tsconfig.host.json | 1 + 16 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 apps/web/tests/markdown-cjk-strong.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md create mode 100644 packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 2a56348913..ea720516fb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58 -2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187 +2026-07-23-web-assistant-markdown.md: 39f01c272a48a2f7e7ae89c15c2edc4efac4a0cb +2026-07-23-web-assistant-markdown.zh.md: 50688aadecb88cb8d92263087eb88e86598af85e diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 8a87783519..39f01c272a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,7 +12,7 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. +`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. @@ -36,6 +36,8 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. +**Preprocess Markdown source or repair text nodes after parsing for CJK punctuation boundaries.** A source rewrite must reproduce escape, code, math, and delimiter rules before the parser owns those distinctions, while a text-node repair has already lost some source intent and cannot compose with parsed inline nodes. Extending attention at the tokenizer boundary preserves the upstream resolver and limits the divergence to delimiter eligibility. + ## Consequences Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 2ac024e24f..50688aadec 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,7 +12,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 @@ -36,6 +36,8 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 +**为处理 CJK 标点边界而预处理 Markdown 源文本,或在解析后修复文本节点。**源文本重写必须在解析器掌握这些区别之前复现转义、代码、数学公式与定界符规则;文本节点修复则已经丢失部分源文本意图,也无法与已解析的行内节点组合。在分词器边界扩展 attention 可保留上游 resolver,并将差异限制在定界符的适用条件上。 + ## 后果 assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7d2e257ea9..077de09a20 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -63,10 +63,12 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | | [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | | [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts new file mode 100644 index 0000000000..dfa669d47d --- /dev/null +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -0,0 +1,128 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-cjk-strong', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-cjk-strong/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-cjk-strong-web-e2e' +const DONE = 'CJK_STRONG_DONE' +const CASES = [ + ['**注意:**内容', '注意:', '注意:内容'], + ['**Notice:**内容', 'Notice:', 'Notice:内容'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'], + ['**句号。**后续', '句号。', '句号。后续'], + ['**Period.**后续', 'Period.', 'Period.后续'], + ['**提醒!**继续', '提醒!', '提醒!继续'], + ['**Warning!**继续', 'Warning!', 'Warning!继续'], +] as const + +/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */ +function markdownFixture(): string { + const session = Session.create(SessionId('markdown-cjk-strong-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'CJK strong emphasis', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## CJK strong emphasis', + '', + ...CASES.flatMap(([markdown]) => [markdown, '']), + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: CJK-adjacent Markdown strong emphasis', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders punctuation-terminated strong spans before adjacent CJK text', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-cjk-strong')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + const strong = page.locator('[class*="markdown"] strong') + await expect.poll(() => strong.count(), { timeout: 10_000 }).toBe(CASES.length) + expect(await strong.allTextContents()).toEqual(CASES.map(([, expected]) => expected)) + for (const [, , paragraph] of CASES) { + expect(await page.getByText(paragraph, { exact: true }).count()).toBe(1) + } + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md new file mode 100644 index 0000000000..68a4df5603 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "CJK strong emphasis" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Render adjacent CJK strong emphasis. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "CJK strong emphasis" [level=2] +- paragraph: + - strong: 注意: + - text: 内容 +- paragraph: + - strong: "Notice:" + - text: 内容 +- paragraph: + - strong: 事件中间件(waterfall) + - text: 实现 +- paragraph: + - strong: 事件中间件(waterfall) + - text: 实现 +- paragraph: + - strong: 句号。 + - text: 后续 +- paragraph: + - strong: Period. + - text: 后续 +- paragraph: + - strong: 提醒! + - text: 继续 +- paragraph: + - strong: Warning! + - text: 继续 +- paragraph: CJK_STRONG_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}}Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 714dfcb2d4..c1d4f52cf6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,6 +51,7 @@ "tests/message-actions.e2e.ts", "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", + "tests/markdown-cjk-strong.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 68f2f87258..09fc51a13c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf -README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43 +README.md: aebcc06bf95b5bd843c2e8add6f89908d7bfa1fa +README.zh.md: 7e141148cee23416e344f57089d425c272e86db4 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 03e7e3649f..aebcc06bf9 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 090ecc34e8..7e141148ce 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 683ca7a93b..8bfbed69ca 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -26,10 +26,12 @@ "katex": "^0.16.47", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", + "micromark-core-commonmark": "^2.0.3", "micromark-extension-gfm": "^3.0.0", "micromark-extension-math": "^3.1.0", "micromark-factory-space": "^2.0.1", "micromark-util-character": "^2.1.1", + "micromark-util-classify-character": "^2.0.1", "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", "react": "^18.2.0", diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 6450de55e2..0fba52c2c5 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -5,13 +5,15 @@ import rehypeKatex from 'rehype-katex' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { CodeBlock } from './CodeBlock.tsx' +import { remarkCjkFriendlyStrong } from './remarkCjkFriendlyStrong.ts' import { remarkMathCompatibility } from './remarkMathCompatibility.ts' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -const streamingRemarkPlugins = [remarkGfm] +const streamingRemarkPlugins = [remarkGfm, remarkCjkFriendlyStrong] const settledRemarkPlugins = [ remarkGfm, + remarkCjkFriendlyStrong, remarkMathCompatibility, remarkMath, ] diff --git a/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts new file mode 100644 index 0000000000..a185483723 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/remarkCjkFriendlyStrong.ts @@ -0,0 +1,88 @@ +/** Let asterisk strong emphasis close after punctuation when CJK prose continues without whitespace. */ + +import { attention } from 'micromark-core-commonmark' +import { unicodePunctuation } from 'micromark-util-character' +import { classifyCharacter } from 'micromark-util-classify-character' +import { codes, constants } from 'micromark-util-symbol' +import type { Construct, Extension, State, Tokenizer } from 'micromark-util-types' + +interface RemarkProcessor { + data(): { micromarkExtensions?: Extension[] } +} + +const cjkCharacter = new RegExp([ + '\\p{Script_Extensions=Han}', + '\\p{Script_Extensions=Hiragana}', + '\\p{Script_Extensions=Katakana}', + '\\p{Script_Extensions=Hangul}', + '\\p{Script_Extensions=Bopomofo}', +].join('|'), 'u') + +function isCjkCharacter(code: number | null): boolean { + return code !== null && code >= 0 && cjkCharacter.test(String.fromCodePoint(code)) +} + +const tokenizeCjkFriendlyAttention: Tokenizer = function (effects, ok, nok) { + const configuredAttentionMarkers = this.parser.constructs.attentionMarkers.null + if (configuredAttentionMarkers === undefined) { + throw new Error('micromark CommonMark attention markers are unavailable') + } + const attentionMarkers = configuredAttentionMarkers + const previous = this.previous + const before = classifyCharacter(previous) + let marker: number | null = codes.eof + + return start + + function start(code: number | null): State | undefined { + /* v8 ignore next -- this text construct is dispatched only for an asterisk. */ + if (code !== codes.asterisk) return nok(code) + marker = code + effects.enter('attentionSequence') + return inside(code) + } + + function inside(code: number | null): State | undefined { + if (code === marker) { + effects.consume(code) + return inside + } + + const token = effects.exit('attentionSequence') + const after = classifyCharacter(code) + const open = !after || (after === constants.characterGroupPunctuation && Boolean(before)) + || attentionMarkers.includes(code) + const commonMarkClose = !before + || (before === constants.characterGroupPunctuation && Boolean(after)) + || attentionMarkers.includes(previous) + const markerCount = token.end.offset - token.start.offset + const cjkStrongClose = markerCount >= 2 + && unicodePunctuation(previous) + && isCjkCharacter(code) + const close = commonMarkClose || cjkStrongClose + + token._open = open + token._close = close + return ok(code) + } +} + +const cjkFriendlyAttention: Construct = { + name: 'cjkFriendlyAttention', + resolveAll: attention.resolveAll, + tokenize: tokenizeCjkFriendlyAttention, +} + +const cjkFriendlyStrong: Extension = { + text: { [codes.asterisk]: cjkFriendlyAttention }, +} + +/** + * Extend CommonMark asterisk strong emphasis for punctuation-delimited CJK prose. + * @returns Nothing. + */ +export function remarkCjkFriendlyStrong(this: RemarkProcessor): undefined { + const data = this.data() + const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) + extensions.push(cjkFriendlyStrong) +} diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 7a858c1199..6b50994c79 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { Extension } from 'micromark-util-types' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { remarkCjkFriendlyStrong } from '../src/markdown/remarkCjkFriendlyStrong.ts' import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts' afterEach(cleanup) @@ -68,6 +69,70 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => { + const cases = [ + ['**注意:**内容', '注意:'], + ['**Notice:**内容', 'Notice:'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**句号。**后续', '句号。'], + ['**Period.**后续', 'Period.'], + ['**提醒!**继续', '提醒!'], + ['**Warning!**继续', 'Warning!'], + ] as const + const source = cases.map(([markdown]) => markdown).join('\n\n') + + for (const streaming of [false, true]) { + const rendered = render() + expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent)) + .toEqual(cases.map(([, strong]) => strong)) + rendered.unmount() + } + }) + + it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => { + const source = [ + String.raw`\**注意:**内容`, + '`**注意:**内容`', + '**Notice:**text', + '*提醒!*继续', + '$**注意:**内容$', + '```md', + '**注意:**内容', + '```', + '**普通**内容', + '*普通*内容', + ].join('\n\n') + const { container } = render() + + expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通']) + expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通']) + expect(container.querySelector('code')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容') + expect(container.textContent).toContain('**Notice:**text') + expect(container.textContent).toContain('*提醒!*继续') + expect(container.textContent).toContain('**注意:**内容') + }) + + it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => { + const data: { micromarkExtensions?: Extension[] } = {} + const processor = { data: () => data } + remarkCjkFriendlyStrong.call(processor) + remarkCjkFriendlyStrong.call(processor) + + expect(data.micromarkExtensions).toHaveLength(2) + const construct = data.micromarkExtensions?.[0]?.text?.[42] + const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize + expect(tokenizer).toBeTypeOf('function') + expect(() => tokenizer?.call({ + parser: { constructs: { attentionMarkers: {} } }, + previous: null, + } as never, {} as never, () => undefined, () => undefined)).toThrow( + 'micromark CommonMark attention markers are unavailable', + ) + }) + it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => { for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { const { container, unmount } = render() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01a5a2f64c..d6c1b637ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1703,6 +1703,9 @@ importers: mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 + micromark-core-commonmark: + specifier: ^2.0.3 + version: 2.0.3 micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 @@ -1715,6 +1718,9 @@ importers: micromark-util-character: specifier: ^2.1.1 version: 2.1.1 + micromark-util-classify-character: + specifier: ^2.0.1 + version: 2.0.1 micromark-util-symbol: specifier: ^2.0.1 version: 2.0.1 diff --git a/tsconfig.host.json b/tsconfig.host.json index c13d480a46..9f7b167e21 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,6 +38,7 @@ "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", + "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", From 178eaf397351218311ce50faf26a8b10f100b5f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:41:41 +0800 Subject: [PATCH 15/27] fix(web): link inline-code URLs --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 6 +- .../2026-07-23-web-assistant-markdown.zh.md | 6 +- .../tests/markdown-inline-code-links.e2e.ts | 138 ++++++++++++++++++ .../markdown-inline-code-links/ui.expected.md | 43 ++++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/MarkdownText.tsx | 54 ++++--- .../ui-primitives/tests/markdown.spec.tsx | 34 +++++ tsconfig.host.json | 1 + 12 files changed, 267 insertions(+), 28 deletions(-) create mode 100644 apps/web/tests/markdown-inline-code-links.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index ea720516fb..febc46c94a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: 39f01c272a48a2f7e7ae89c15c2edc4efac4a0cb -2026-07-23-web-assistant-markdown.zh.md: 50688aadecb88cb8d92263087eb88e86598af85e +2026-07-23-web-assistant-markdown.md: c21f2dc3c0aff98dd6ea6a88ea7a4742198c92d6 +2026-07-23-web-assistant-markdown.zh.md: e6677a6a22050b59343a67d6b282b92695c24009 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 39f01c272a..c21f2dc3c0 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -14,7 +14,7 @@ The Web conversation preserves assistant Markdown source through session events, `MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. -Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. @@ -38,6 +38,8 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Preprocess Markdown source or repair text nodes after parsing for CJK punctuation boundaries.** A source rewrite must reproduce escape, code, math, and delimiter rules before the parser owns those distinctions, while a text-node repair has already lost some source intent and cannot compose with parsed inline nodes. Extending attention at the tokenizer boundary preserves the upstream resolver and limits the divergence to delimiter eligibility. +**Require the model to emit standard links and leave URL-shaped inline code inert.** Output guidance cannot make persisted or third-party model replies uniform, and inline code is a common way to distinguish a literal endpoint. Recognizing only a complete absolute HTTP(S) value at the rendered inline-code boundary preserves code semantics while applying the existing untrusted-link policy. + ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. URL-shaped inline code becomes navigable without changing its visible literal, while unsafe schemes and mixed code remain non-interactive. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 50688aadec..e6677a6a22 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -14,7 +14,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 -视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 @@ -38,6 +38,8 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **为处理 CJK 标点边界而预处理 Markdown 源文本,或在解析后修复文本节点。**源文本重写必须在解析器掌握这些区别之前复现转义、代码、数学公式与定界符规则;文本节点修复则已经丢失部分源文本意图,也无法与已解析的行内节点组合。在分词器边界扩展 attention 可保留上游 resolver,并将差异限制在定界符的适用条件上。 +**要求模型输出标准链接,并让 URL 形态的行内代码保持不可交互。**输出指引无法统一已持久化回复与第三方模型回复,而行内代码是将端点标记为字面值的常见方式。仅在行内代码的渲染边界识别完整的绝对 HTTP(S) 值,可在应用现有不受信任链接策略的同时保留代码语义。 + ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。URL 形态的行内代码会在不改变其可见字面文本的情况下变得可导航,而采用不安全 scheme 或混有其他内容的代码仍不可交互。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts new file mode 100644 index 0000000000..8839a1b8d1 --- /dev/null +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -0,0 +1,138 @@ +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-inline-code-links', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-inline-code-links/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-inline-code-links-web-e2e' +const DONE = 'INLINE_CODE_LINK_DONE' + +/** Build a settled assistant reply with linkable URL code and inert code controls. */ +function markdownFixture(linkUrl: string): string { + const session = Session.create(SessionId('markdown-inline-code-links-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the local preview URL.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Inline code links', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Inline code links', + '', + `Preview: \`${linkUrl}\``, + '', + `Standard: [Open preview](${linkUrl})`, + '', + `Command: \`curl ${linkUrl}\``, + '', + 'Unsafe: `javascript:alert(1)`', + '', + DONE, + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: Markdown inline-code links', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let linkUrl: string + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString() + await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('opens a complete HTTP URL from inline code and leaves other code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-inline-code-links')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + const inlineCodeLink = page.locator('[class*="markdown"] code a') + await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1) + expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl) + expect(await inlineCodeLink.getAttribute('target')).toBe('_blank') + expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer') + await inlineCodeLink.focus() + expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true) + + const popupPromise = page.waitForEvent('popup') + await inlineCodeLink.click() + const popup = await popupPromise + await popup.waitForURL(linkUrl, { timeout: 15_000 }) + expect(popup.url()).toBe(linkUrl) + await popup.close() + + expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0) + expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + .split(linkUrl).join('{{linkUrl}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md new file mode 100644 index 0000000000..059849223c --- /dev/null +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -0,0 +1,43 @@ +- banner: + - navigation "Session hierarchy": + - button "Inline code links" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the local preview URL. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- heading "Inline code links" [level=2] +- paragraph: + - text: "Preview:" + - code: + - link "{{linkUrl}}": + - /url: {{linkUrl}} +- paragraph: + - text: "Standard:" + - link "Open preview": + - /url: {{linkUrl}} +- paragraph: + - text: "Command:" + - code: curl {{linkUrl}} +- paragraph: + - text: "Unsafe:" + - code: javascript:alert(1) +- paragraph: INLINE_CODE_LINK_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}}Ran for {{duration}} +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model": + - text: Select model + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c1d4f52cf6..dd5fe879e7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -52,6 +52,7 @@ "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", + "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/permission-policy-context.e2e.ts", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 09fc51a13c..6de113572f 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: aebcc06bf95b5bd843c2e8add6f89908d7bfa1fa -README.zh.md: 7e141148cee23416e344f57089d425c272e86db4 +README.md: c64e86152737fd55c49ac39cc2e7b523e0323774 +README.zh.md: 29123c3570122bc0fe6a1808a75c5bc659315eaa diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index aebcc06bf9..c64e861527 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 7e141148ce..29123c3570 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 0fba52c2c5..b09f1f46fe 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,4 +1,4 @@ -import { isValidElement, useMemo } from 'react' +import { isValidElement, useMemo, type ReactNode } from 'react' import ReactMarkdown from 'react-markdown' import type { Components, UrlTransform } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -36,6 +36,30 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) +function renderSafeLink(href: string, children: ReactNode): ReactNode { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) +} + +function inlineCodeHttpUrl(value: string): string | undefined { + if (value.trim() !== value) return undefined + try { + const protocol = new URL(value).protocol + return protocol === 'http:' || protocol === 'https:' ? value : undefined + } catch { + return undefined + } +} + /** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ export interface MarkdownCodeLabels { /** Copy-button idle label. */ @@ -56,18 +80,10 @@ function remoteImageUrl(url: string): string | undefined { /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components { return { - a: ({ href = '', children }) => { - const safeHref = sanitizeUrl(href) - if (safeHref === '') return <>{children} - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) - return ( - - {children} - - ) + a: ({ href = '', children }) => renderSafeLink(href, children), + code: ({ className, children }) => { + const href = typeof children === 'string' ? inlineCodeHttpUrl(children) : undefined + return {href === undefined ? children : renderSafeLink(href, children)} }, img: ({ alt = '', src = '' }) => { const imageSrc = remoteImageUrl(src) @@ -90,10 +106,11 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C ), // Fenced blocks route through the shared CodeBlock (shiki for registered // grammars, identical-geometry plain fallback for unknown/absent - // languages); inline code keeps the default path (the :not(pre) - // rule styles it). While the message streams, the fence renders the - // plain arm — retokenizing a growing fence on every chunk is quadratic - // main-thread work; the finalize swap highlights it once. + // languages); inline code keeps the path (the :not(pre) rule + // styles it), with a safe anchor only for complete HTTP(S) values. While + // the message streams, the fence renders the plain arm — retokenizing a + // growing fence on every chunk is quadratic main-thread work; the + // finalize swap highlights it once. pre: ({ children }) => { // The markdown pipeline always hands `pre` its single `code` element; // the undefined arm guards a react-markdown representation change. @@ -128,7 +145,8 @@ const streamingComponents = buildComponents(true) * component table memoizes on its identity and a fresh literal per render * would rebuild it every streaming chunk. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, - * relative links, and unsafe protocols are disabled, while absolute HTTP(S) + * relative links, and unsafe protocols are disabled; complete HTTP(S) + * inline-code values become safe external links, while absolute HTTP(S) * images render directly. */ export function MarkdownText({ text, streaming = false, codeLabels }: { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 6b50994c79..e67302a305 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -115,6 +115,40 @@ describe('MarkdownText', () => { expect(container.textContent).toContain('**注意:**内容') }) + it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => { + const localUrl = 'http://127.0.0.1:3199/?demo=1' + const remoteUrl = 'https://example.com/preview?q=one%20two#result' + const source = [ + `\`${localUrl}\``, + `\`${remoteUrl}\``, + '`curl http://127.0.0.1:3199/?demo=1`', + '`javascript:alert(1)`', + '`mailto:dev@example.com`', + `\` ${localUrl} \``, + '```', + localUrl, + '```', + ].join('\n\n') + const { container } = render() + + const links = screen.getAllByRole('link') + expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl]) + for (const link of links) { + expect(link.closest('code')).not.toBeNull() + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + } + links[0]?.focus() + expect(document.activeElement).toBe(links[0]) + expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull() + expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull() + expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull() + const paddedCode = [...container.querySelectorAll('code')] + .find(code => code.textContent === ` ${localUrl} `) + expect(paddedCode?.querySelector('a')).toBeNull() + expect(container.querySelector('pre code a')).toBeNull() + }) + it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => { const data: { micromarkExtensions?: Extension[] } = {} const processor = { data: () => data } diff --git a/tsconfig.host.json b/tsconfig.host.json index 9f7b167e21..4fcf71b680 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -39,6 +39,7 @@ "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", + "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", From 8d6824a84be0ec4b1dd515cd3bb3b84d78707d54 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 6 Aug 2026 13:56:43 +0800 Subject: [PATCH 16/27] =?UTF-8?q?feat:=20markdown=20=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...arkdown-incremental-ast-renderer.i18n.yaml | 6 + ...6-web-markdown-incremental-ast-renderer.md | 33 ++ ...eb-markdown-incremental-ast-renderer.zh.md | 33 ++ ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 8 +- .../2026-07-23-web-assistant-markdown.zh.md | 8 +- THIRD_PARTY_NOTICES.md | 8 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 +- packages/client/ui-primitives/README.zh.md | 3 +- packages/client/ui-primitives/package.json | 7 +- .../src/markdown/MarkdownText.tsx | 281 +++++----- .../ui-primitives/src/markdown/incremental.ts | 121 +++++ .../ui-primitives/src/markdown/katex.tsx | 84 +++ ...hCompatibility.ts => mathCompatibility.ts} | 18 +- .../ui-primitives/src/markdown/parse.ts | 41 ++ .../ui-primitives/src/markdown/render.tsx | 512 ++++++++++++++++++ .../blockquote-nested.settled.txt | 12 + .../blockquote-nested.streaming.txt | 12 + .../markdown-dom/code-fences.settled.txt | 78 +++ .../markdown-dom/code-fences.streaming.txt | 53 ++ .../markdown-dom/definition-only.settled.txt | 1 + .../definition-only.streaming.txt | 1 + .../entities-and-escapes.settled.txt | 3 + .../entities-and-escapes.streaming.txt | 3 + .../markdown-dom/footnotes.settled.txt | 29 + .../markdown-dom/footnotes.streaming.txt | 29 + ...gfm-strikethrough-and-literals.settled.txt | 12 + ...m-strikethrough-and-literals.streaming.txt | 12 + .../hard-breaks-and-hr.settled.txt | 12 + .../hard-breaks-and-hr.streaming.txt | 12 + .../heading-tight-against-list.settled.txt | 15 + .../heading-tight-against-list.streaming.txt | 15 + .../headings-and-paragraphs.settled.txt | 33 ++ .../headings-and-paragraphs.streaming.txt | 33 ++ .../fixtures/markdown-dom/images.settled.txt | 14 + .../markdown-dom/images.streaming.txt | 14 + .../inline-code-with-newline.settled.txt | 6 + .../inline-code-with-newline.streaming.txt | 6 + .../links-and-autolinks.settled.txt | 25 + .../links-and-autolinks.streaming.txt | 25 + .../lists-tight-loose-nested.settled.txt | 44 ++ .../lists-tight-loose-nested.streaming.txt | 44 ++ .../markdown-dom/math-edge-cases.settled.txt | 125 +++++ .../math-edge-cases.streaming.txt | 29 + .../math-inline-and-display.settled.txt | 320 +++++++++++ .../math-inline-and-display.streaming.txt | 9 + .../markdown-dom/raw-html-dropped.settled.txt | 7 + .../raw-html-dropped.streaming.txt | 7 + .../reference-links-and-images.settled.txt | 16 + .../reference-links-and-images.streaming.txt | 16 + .../streaming-typical-partial.settled.txt | 8 + .../streaming-typical-partial.streaming.txt | 8 + .../table-with-alignment.settled.txt | 35 ++ .../table-with-alignment.streaming.txt | 35 ++ .../markdown-dom/task-lists.settled.txt | 19 + .../markdown-dom/task-lists.streaming.txt | 19 + .../tests/markdown-dom-parity.spec.tsx | 232 ++++++++ .../tests/markdown-incremental.spec.tsx | 419 ++++++++++++++ .../tests/markdown-render-units.spec.tsx | 225 ++++++++ .../ui-primitives/tests/markdown.spec.tsx | 11 +- pnpm-lock.yaml | 406 +------------- 62 files changed, 3090 insertions(+), 573 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md create mode 100644 packages/client/ui-primitives/src/markdown/incremental.ts create mode 100644 packages/client/ui-primitives/src/markdown/katex.tsx rename packages/client/ui-primitives/src/markdown/{remarkMathCompatibility.ts => mathCompatibility.ts} (95%) create mode 100644 packages/client/ui-primitives/src/markdown/parse.ts create mode 100644 packages/client/ui-primitives/src/markdown/render.tsx create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/gfm-strikethrough-and-literals.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/gfm-strikethrough-and-literals.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/inline-code-with-newline.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/inline-code-with-newline.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt create mode 100644 packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx create mode 100644 packages/client/ui-primitives/tests/markdown-incremental.spec.tsx create mode 100644 packages/client/ui-primitives/tests/markdown-render-units.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml new file mode 100644 index 0000000000..c5634f42ee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md +2026-08-06-web-markdown-incremental-ast-renderer.md: 98d28fa27f8e6e4b5f2ac21831c8f80f8c0c3631 +2026-08-06-web-markdown-incremental-ast-renderer.zh.md: c2c19fe36b8290c1802f6e2b321336e1a68d686f diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md new file mode 100644 index 0000000000..98d28fa27f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md @@ -0,0 +1,33 @@ +# Agent Note: Incremental streaming markdown through a direct mdast renderer + +Status: implemented + +English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md) + +## Problem + +`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express. + +## Decision + +`MarkdownText` renders mdast directly and parses incrementally while streaming: + +- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly. +- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation. +- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized. + +The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior. + +This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged. + +## Alternatives considered + +**Keep react-markdown and split the source into per-segment `` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances. + +**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures. + +**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically. + +## Consequences + +Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did. diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md new file mode 100644 index 0000000000..c2c19fe36b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown + +Status: implemented + +[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文 + +## Problem + +`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。 + +## Decision + +`MarkdownText` 直接渲染 mdast,并在流式期间增量解析: + +- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。 +- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。 +- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。 + +DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。 + +这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。 + +## Alternatives considered + +**保留 react-markdown,把源文本切成逐段 `` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。 + +**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。 + +**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。 + +## Consequences + +流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math` 与 `micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 2a56348913..833ccb9886 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58 -2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187 +2026-07-23-web-assistant-markdown.md: 61fdac4d3276044d28a6b12759e81de9ff95b63a +2026-07-23-web-assistant-markdown.zh.md: 9f618ee83e7597884895548cd16c4b2e7f1bd57c diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 8a87783519..61fdac4d32 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -12,9 +12,9 @@ The Web conversation preserves assistant Markdown source through session events, `@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. -`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. +`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk. -Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. +Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle. @@ -26,7 +26,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen ## Alternatives considered -**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. +**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.* **Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly. @@ -38,4 +38,4 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen ## Consequences -Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses only the unstable tail after each accumulated update; incomplete Markdown can temporarily change the tail's structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index 2ac024e24f..9f618ee83e 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -12,9 +12,9 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 +`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性契约)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 -视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 +视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。 @@ -26,7 +26,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 ## 考虑过的替代方案 -**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。 +**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。* **将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。 @@ -38,4 +38,4 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 ## 后果 -assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出只重新解析不稳定的尾部;未完成的 Markdown 可能暂时改变尾部结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7d2e257ea9..e9da980aab 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -63,10 +64,12 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`koffi`](https://github.com/Koromix/koffi) | MIT | | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | | [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | | [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | @@ -75,10 +78,6 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | -| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT | -| [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT | -| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT | -| [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | | [`tsx`](https://github.com/privatenumber/tsx) | MIT | @@ -109,7 +108,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 68f2f87258..f0030907b2 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf -README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43 +README.md: 04fb50eedb59f1028145b4985a0cb7560d388492 +README.zh.md: 753905915f6ac501ade9e8473ad747c1356e1556 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 03e7e3649f..04fb50eedb 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output @@ -42,6 +42,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. Inline links and references resolved within one parse are unaffected. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 090ecc34e8..753905915f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 @@ -42,6 +42,7 @@ ## 已知限制与暂缓事项 +- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。内联链接以及在同一次解析内完成解析的引用不受影响。 - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 683ca7a93b..c871d46ab4 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -21,23 +21,22 @@ "license": "BSD-3-Clause", "dependencies": { "@shikijs/langs": "^4.3.1", + "@types/mdast": "^4.0.4", "anser": "^2.3.5", "clsx": "^2.0.0", "katex": "^0.16.47", "mdast-util-from-markdown": "^2.0.3", "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-extension-math": "^3.1.0", "micromark-factory-space": "^2.0.1", "micromark-util-character": "^2.1.1", + "micromark-util-sanitize-uri": "^2.0.1", "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-markdown": "^10.1.0", - "rehype-katex": "^7.0.1", - "remark-gfm": "^4.0.1", - "remark-math": "^6.0.0", "shiki": "^4.3.1" }, "devDependencies": { diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 6450de55e2..bb4b62514c 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,155 +1,164 @@ -import { isValidElement, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import type { Components, UrlTransform } from 'react-markdown' -import rehypeKatex from 'rehype-katex' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' -import { CodeBlock } from './CodeBlock.tsx' -import { remarkMathCompatibility } from './remarkMathCompatibility.ts' +/** + * Untrusted assistant-Markdown renderer over the direct mdast pipeline: + * `parse.ts` grammars, the incremental streaming parser, and `render.tsx`. + * While a message streams, all but the trailing two blocks freeze as cached + * React elements and only the source tail behind them re-parses per chunk, + * so per-chunk work tracks the tail size instead of the whole reply. Frozen + * blocks keep their source-offset keys when they cross the freeze boundary, + * so React reconciles instead of remounting. Known deviation while + * streaming: a reference-style link or footnote whose definition sits on the + * other side of the freeze boundary renders literally until the settled + * full parse self-heals it. + */ + +import { memo, useMemo, useRef } from 'react' +import type { ReactNode } from 'react' +import { IncrementalMarkdownParser } from './incremental.ts' +import { parseGfm, parseGfmWithMath } from './parse.ts' +import { + collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, + wrapBlockChildren, +} from './render.tsx' +import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -const streamingRemarkPlugins = [remarkGfm] -const settledRemarkPlugins = [ - remarkGfm, - remarkMathCompatibility, - remarkMath, -] -const settledRehypePlugins = [rehypeKatex] +export type { MarkdownCodeLabels } from './render.tsx' -function sanitizeUrl(url: string): string { - try { - switch (new URL(url).protocol) { - case 'http:': - case 'https:': - case 'mailto:': - return url - default: - return '' +/** One settled full render: parse with math, resolve references, append the footnote section. */ +function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] { + const root = parseGfmWithMath(text) + const targets = createReferenceTargets() + collectReferenceTargets(root.children, targets) + const context: MarkdownRenderContext = { + streaming: false, + codeLabels, + targets, + footnoteOrder: [], + footnoteCounts: new Map(), + } + const blocks = wrapBlockChildren( + renderBlocks(root.children.map((node, index) => ({ node, key: index })), context), + false, + ) + const section = renderFootnoteSection(context) + return section === null ? blocks : [...blocks, '\n', section] +} + +/** + * Streaming render state for one growing message: the incremental parser, + * the frozen blocks' cached elements, and the reference/footnote state their + * rendering consumed (footnote numbering assigned to frozen references is + * final, so the tail continues from a copy of it each frame). + */ +class StreamingRenderer { + private readonly parser = new IncrementalMarkdownParser(parseGfm) + private generation = -1 + private frozenCount = 0 + private frozenElements: ReactNode[] = [] + private frozenTargets: ReferenceTargets = createReferenceTargets() + private frozenFootnoteOrder: string[] = [] + private frozenFootnoteCounts = new Map() + private lastText: string | null = null + private lastRendered: ReactNode[] = [] + + /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */ + constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {} + + /** + * Render the current accumulated text. Idempotent per text value, so React + * may re-execute the calling render freely. + * @param text - The full accumulated markdown source. + * @returns Frozen elements, re-rendered tail, and the footnote section. + */ + render(text: string): ReactNode[] { + if (text === this.lastText) return this.lastRendered + const { frozen, tail, generation } = this.parser.update(text) + if (generation !== this.generation) { + this.generation = generation + this.frozenCount = 0 + this.frozenElements = [] + this.frozenTargets = createReferenceTargets() + this.frozenFootnoteOrder = [] + this.frozenFootnoteCounts = new Map() } - } catch { - return '' + const newlyFrozen = frozen.slice(this.frozenCount) + collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets) + // Targets visible this frame: everything frozen so far plus the current + // tail parse — a newly frozen block's references resolved against the + // same parse tree its definitions came from. + const frameTargets: ReferenceTargets = { + definitions: new Map(this.frozenTargets.definitions), + footnotes: new Map(this.frozenTargets.footnotes), + } + collectReferenceTargets(tail.map(block => block.node), frameTargets) + if (newlyFrozen.length > 0) { + const frozenContext: MarkdownRenderContext = { + streaming: true, + codeLabels: this.codeLabels, + targets: frameTargets, + footnoteOrder: this.frozenFootnoteOrder, + footnoteCounts: this.frozenFootnoteCounts, + } + // Separator newlines are cached alongside the elements so the + // assembled children match the settled pipeline's block wrapping. + const batch = [...this.frozenElements] + for (const element of renderBlocks(newlyFrozen, frozenContext)) { + if (batch.length > 0) batch.push('\n') + batch.push(element) + } + this.frozenElements = batch + this.frozenCount = frozen.length + } + const tailContext: MarkdownRenderContext = { + streaming: true, + codeLabels: this.codeLabels, + targets: frameTargets, + footnoteOrder: [...this.frozenFootnoteOrder], + footnoteCounts: new Map(this.frozenFootnoteCounts), + } + const children = [...this.frozenElements] + for (const element of renderBlocks(tail, tailContext)) { + if (children.length > 0) children.push('\n') + children.push(element) + } + const section = renderFootnoteSection(tailContext) + if (section !== null) children.push('\n', section) + this.lastText = text + this.lastRendered = children + return this.lastRendered } } -const safeUrl: UrlTransform = url => sanitizeUrl(url) - -/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ -export interface MarkdownCodeLabels { - /** Copy-button idle label. */ - copyLabel?: string | undefined - /** Copy-button label during the post-copy confirmation window. */ - copiedLabel?: string | undefined -} - -function remoteImageUrl(url: string): string | undefined { - try { - const protocol = new URL(url).protocol - return protocol === 'http:' || protocol === 'https:' ? url : undefined - } catch { - return undefined - } -} - -/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ -function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components { - return { - a: ({ href = '', children }) => { - const safeHref = sanitizeUrl(href) - if (safeHref === '') return <>{children} - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) - return ( - - {children} - - ) - }, - img: ({ alt = '', src = '' }) => { - const imageSrc = remoteImageUrl(src) - if (imageSrc === undefined) return {alt} - return ( - {alt} - ) - }, - table: ({ children }) => ( -
- {children}
-
- ), - // Fenced blocks route through the shared CodeBlock (shiki for registered - // grammars, identical-geometry plain fallback for unknown/absent - // languages); inline code keeps the default path (the :not(pre) - // rule styles it). While the message streams, the fence renders the - // plain arm — retokenizing a growing fence on every chunk is quadratic - // main-thread work; the finalize swap highlights it once. - pre: ({ children }) => { - // The markdown pipeline always hands `pre` its single `code` element; - // the undefined arm guards a react-markdown representation change. - /* v8 ignore next 2 */ - const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined - const raw = child?.props.children - // A fence whose content isn't one plain string (e.g. an empty fence) - // keeps the stock
 rather than guessing.
-      if (typeof raw !== 'string') return 
{children}
- const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] - return ( - - ) - }, - } -} - -const staticComponents = buildComponents(false) -const streamingComponents = buildComponents(true) - /** * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; - * `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap); - * `codeLabels` forwards localized copy-button labels to fence CodeBlocks — - * pass a reference-stable object (memoized per locale revision), because the - * component table memoizes on its identity and a fresh literal per render - * would rebuild it every streaming chunk. + * `streaming` renders fences and TeX plain (highlighting and KaTeX land on + * the finalize swap) and parses incrementally across chunks; `codeLabels` + * forwards localized copy-button labels to fence CodeBlocks — pass a + * reference-stable object (memoized per locale revision), because a new + * identity discards the streaming render cache mid-message. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export function MarkdownText({ text, streaming = false, codeLabels }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: { text: string streaming?: boolean codeLabels?: MarkdownCodeLabels | undefined }) { - // The label-free tables stay module-level singletons so the common case - // keeps referential stability across renders without a hook. - const components = useMemo(() => { - if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents - return buildComponents(streaming, codeLabels) - }, [streaming, codeLabels]) - return ( -
- - {text} - -
- ) -} + const streamRef = useRef(null) + const streamLabelsRef = useRef(codeLabels) + const children = useMemo(() => { + if (!streaming) { + streamRef.current = null + return renderSettled(text, codeLabels) + } + if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { + streamRef.current = new StreamingRenderer(codeLabels) + streamLabelsRef.current = codeLabels + } + return streamRef.current.render(text) + }, [text, streaming, codeLabels]) + return
{children}
+}) diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts new file mode 100644 index 0000000000..af56232e3a --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/incremental.ts @@ -0,0 +1,121 @@ +/** + * Incremental block-level markdown parsing for an append-only text stream. + * + * Re-parsing the whole accumulated document on every streaming chunk is + * quadratic in the final reply length. CommonMark block parsing is line-based + * and appended text can only reshape the parse frontier — the last top-level + * block (a paragraph becoming a setext heading or a table, a list continuing + * after a blank line, an unclosed fence swallowing lines) — so earlier blocks + * are final. This parser therefore freezes all but the trailing + * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail + * behind them: each source region is parsed O(1) times over the stream + * instead of once per chunk. + * + * The freeze boundary comes from the parser's own `position` offsets, never + * from custom source scanning. The cut sits at the *end offset* of the last + * frozen block (not the next block's start): a following block's start offset + * excludes up to three spaces of insignificant leading indentation, which is + * harmless to drop, but cutting at the previous end also keeps the + * inter-block blank lines in the tail so the sliced source stays verbatim. + * + * Known deviation, shared with any prefix-freeze scheme: micromark resolves + * reference-style links and footnotes document-wide at parse time, so a + * reference whose definition lands on the other side of the freeze boundary + * renders literally until the settled full parse self-heals it. + */ + +import type { Root, RootContent } from 'mdast' + +/** + * Trailing blocks kept unstable. Appended text reshapes at most the last + * block; the second-to-last is retained as safety margin so a freeze decision + * never has to reason about the parse frontier. + */ +const UNSTABLE_TAIL_BLOCKS = 2 + +/** A top-level mdast block plus a render key that is stable across chunks. */ +export interface PositionedBlock { + /** The parsed block. Positions inside it are relative to its parse slice. */ + readonly node: RootContent + /** + * The block's start offset in the full source text. Stable from the frame + * a block first appears through freezing, so React reconciles rather than + * remounts when a block crosses the freeze boundary. + */ + readonly key: number +} + +/** One {@link IncrementalMarkdownParser.update} result. */ +export interface IncrementalBlocks { + /** Blocks that can no longer change; grows monotonically per generation. */ + readonly frozen: readonly PositionedBlock[] + /** The re-parsed unstable tail (at most {@link UNSTABLE_TAIL_BLOCKS} blocks plus growth). */ + readonly tail: readonly PositionedBlock[] + /** Bumped whenever non-append input discards the frozen prefix; callers drop caches keyed on it. */ + readonly generation: number +} + +/** + * A block's render key: its absolute source start offset. A position-less + * node (a grammar is free to omit positions) falls back to a negative + * list-index key, which keeps sibling keys unique without inventing offsets. + */ +function blockKey(node: RootContent, base: number, index: number): number { + const offset = node.position?.start.offset + return offset === undefined ? -(index + 1) : base + offset +} + +/** + * Append-only incremental parser over a caller-supplied grammar. One instance + * accumulates one streaming document; non-append input resets it. + */ +export class IncrementalMarkdownParser { + private prevText = '' + private tailStart = 0 + private frozen: PositionedBlock[] = [] + private generation = 0 + private cached: IncrementalBlocks | null = null + + /** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */ + constructor(private readonly parse: (text: string) => Root) {} + + /** + * Fold the current accumulated text and return the frozen/tail split. + * Idempotent for identical input (the previous result is returned as-is), + * so callers may invoke it from render paths that re-execute. + * @param text - The full accumulated markdown source. + * @returns Frozen and tail blocks with stream-stable render keys. + */ + update(text: string): IncrementalBlocks { + if (this.cached !== null && text === this.prevText) return this.cached + if (!text.startsWith(this.prevText)) { + this.prevText = '' + this.tailStart = 0 + this.frozen = [] + this.generation += 1 + } + this.prevText = text + const base = this.tailStart + const blocks = this.parse(text.slice(base)).children + let firstUnstable = Math.max(0, blocks.length - UNSTABLE_TAIL_BLOCKS) + if (firstUnstable > 0) { + const cutEnd = blocks[firstUnstable - 1]?.position?.end.offset + if (cutEnd === undefined) { + // A grammar that omits positions leaves nothing to cut at; keep the + // whole parse in the tail rather than guessing a boundary. + firstUnstable = 0 + } else { + for (const node of blocks.slice(0, firstUnstable)) { + this.frozen.push({ node, key: blockKey(node, base, this.frozen.length) }) + } + this.tailStart = base + cutEnd + } + } + const tail = blocks.slice(firstUnstable).map((node, index) => ({ + node, + key: blockKey(node, base, index), + })) + this.cached = { frozen: [...this.frozen], tail, generation: this.generation } + return this.cached + } +} diff --git a/packages/client/ui-primitives/src/markdown/katex.tsx b/packages/client/ui-primitives/src/markdown/katex.tsx new file mode 100644 index 0000000000..bae1aa5104 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/katex.tsx @@ -0,0 +1,84 @@ +/** + * TeX-to-React via KaTeX, replicating the rehype-katex pipeline this renderer + * replaced: the same three-arm error chain (strict render, `strict: 'ignore'` + * retry, error span) and a DOM-identical element tree, so settled math keeps + * its exact markup. KaTeX emits an HTML string; the browser's own HTML parser + * (`DOMParser`, applying the spec's SVG/MathML foreign-content attribute + * adjustments KaTeX output relies on) turns it into a tree this module maps + * onto React elements — KaTeX output is a static span/MathML/SVG vocabulary + * with no raw user HTML, the same trust shiki's tree gets in CodeBlock. + */ + +import { createElement } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import katex from 'katex' + +/** + * Convert one inline `style` attribute string into React's style object. + * KaTeX emits only plain kebab-case declarations (no custom properties and no + * nameless declarations), so camel-casing the property is the whole mapping. + */ +function styleObject(css: string): CSSProperties { + const style: Record = {} + for (const declaration of css.split(';')) { + const colon = declaration.indexOf(':') + if (colon === -1) continue + const name = declaration.slice(0, colon).trim() + const key = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()) + style[key] = declaration.slice(colon + 1).trim() + } + return style +} + +/** Map one parsed DOM node onto a React element (text nodes pass through). */ +function domToReact(node: ChildNode, key: number): ReactNode { + if (node.nodeType === Node.TEXT_NODE) return node.textContent + /* v8 ignore next 2 -- KaTeX output holds only elements and text; other + node kinds cannot appear in its serialized vocabulary. */ + if (node.nodeType !== Node.ELEMENT_NODE) return null + const element = node as Element + const props: Record = { key } + for (const attribute of element.attributes) { + if (attribute.name === 'class') props['className'] = attribute.value + else if (attribute.name === 'style') props['style'] = styleObject(attribute.value) + else props[attribute.name] = attribute.value + } + const children = [...element.childNodes].map(domToReact) + return children.length === 0 + ? createElement(element.localName, props) + : createElement(element.localName, props, ...children) +} + +/** + * Render TeX source to React elements through KaTeX. + * @param value - The TeX source (math node value; fenced `math` blocks append + * their trailing newline to match the replaced pipeline's text extraction). + * @param displayMode - Display (block) versus inline rendering. + * @returns KaTeX's element tree, or the error span when the source does not + * parse (colored with KaTeX's stock `errorColor`, matching rehype-katex). + */ +export function renderTexToReact(value: string, displayMode: boolean): ReactNode { + let html: string + try { + html = katex.renderToString(value, { displayMode, throwOnError: true }) + } catch (error) { + try { + html = katex.renderToString(value, { displayMode, strict: 'ignore', throwOnError: false }) + } catch { + // KaTeX renders ParseErrors itself under throwOnError: false; only its + // internal errors reach here, so mirror rehype-katex's manual span. + /* v8 ignore next 8 */ + return ( + + {value} + + ) + } + } + const parsed = new DOMParser().parseFromString(html, 'text/html') + return [...parsed.body.childNodes].map(domToReact) +} diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts similarity index 95% rename from packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts rename to packages/client/ui-primitives/src/markdown/mathCompatibility.ts index dcd8c32362..3edd9d1e63 100644 --- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts @@ -8,10 +8,6 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark // oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback. -interface RemarkProcessor { - data(): { micromarkExtensions?: Extension[] } -} - const previousBackslash: Previous = function (code) { if (code !== codes.backslash) return true const tail = this.events.at(-1) @@ -342,12 +338,12 @@ const backslashMath: Extension = { } /** - * Add TeX backslash delimiters and same-line display-dollar blocks for remark-math. - * The same processor must register remark-math to compile the emitted math tokens. - * @returns Nothing. + * TeX backslash delimiters and same-line display-dollar blocks as a micromark + * syntax extension reusing `micromark-extension-math`'s token vocabulary; the + * caller must also register `math()` on the same parse so the emitted tokens + * compile to standard math nodes. + * @returns The micromark syntax extension. */ -export function remarkMathCompatibility(this: RemarkProcessor): undefined { - const data = this.data() - const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = []) - extensions.push(backslashMath) +export function mathCompatibility(): Extension { + return backslashMath } diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts new file mode 100644 index 0000000000..98482a809d --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -0,0 +1,41 @@ +/** + * The markdown renderer's two mdast grammars, one per rendering arm. Both are + * built from the same micromark extensions, so block boundaries and inline + * semantics are identical wherever a document (or a document tail) is parsed: + * the incremental streaming path, the settled path, and the plain-text + * projection all agree on where blocks start and end. + */ + +import type { Root } from 'mdast' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { mathFromMarkdown } from 'mdast-util-math' +import { gfm } from 'micromark-extension-gfm' +import { math } from 'micromark-extension-math' +import { mathCompatibility } from './mathCompatibility.ts' + +/** + * Parse GFM markdown (the streaming arm's grammar: no math, so incomplete + * TeX never flashes KaTeX errors mid-stream). + * @param text - Markdown source. + * @returns The mdast root. + */ +export function parseGfm(text: string): Root { + return fromMarkdown(text, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }) +} + +/** + * Parse GFM markdown plus TeX math with the compatibility delimiters + * (the settled arm's grammar). + * @param text - Markdown source. + * @returns The mdast root. + */ +export function parseGfmWithMath(text: string): Root { + return fromMarkdown(text, { + extensions: [gfm(), mathCompatibility(), math()], + mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()], + }) +} diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx new file mode 100644 index 0000000000..786a1a4636 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -0,0 +1,512 @@ +/** + * Direct mdast→React markdown renderer. Replaces the react-markdown / + * remark-rehype pipeline with one switch over parsed nodes so streaming can + * cache frozen blocks as React elements; the rendered DOM is pinned + * byte-for-byte by `tests/fixtures/markdown-dom` and must not drift. + * + * Untrusted-output policy (unchanged from the replaced pipeline): link and + * image destinations pass a protocol allowlist, images additionally require + * absolute HTTP(S), raw HTML renders as literal text (no HTML enters the + * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail + * the allowlist, so footnote references and back-references render as plain + * text rather than in-page links. + * + * Merge-extensible node unions fall through the documented default (render + * nothing) rather than ending in assertNever: grammars registered elsewhere + * may add node types this renderer has no mapping for. + */ + +import { Fragment, createElement } from 'react' +import type { Key, ReactNode } from 'react' +import type * as Md from 'mdast' +import type {} from 'mdast-util-math' +import { normalizeUri } from 'micromark-util-sanitize-uri' +import { CodeBlock } from './CodeBlock.tsx' +import { renderTexToReact } from './katex.tsx' +import type { PositionedBlock } from './incremental.ts' +import css from './MarkdownText.module.css' + +/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */ +export interface MarkdownCodeLabels { + /** Copy-button idle label. */ + copyLabel?: string | undefined + /** Copy-button label during the post-copy confirmation window. */ + copiedLabel?: string | undefined +} + +function sanitizeUrl(url: string): string { + try { + switch (new URL(url).protocol) { + case 'http:': + case 'https:': + case 'mailto:': + return url + default: + return '' + } + } catch { + // Relative and otherwise unparsable destinations are disallowed alongside + // disallowed protocols; new URL() has no other failure mode for strings. + return '' + } +} + +function remoteImageUrl(url: string): string | undefined { + try { + const protocol = new URL(url).protocol + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + // Same single failure mode as above: not an absolute URL. + return undefined + } +} + +/** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */ +export interface ReferenceTargets { + /** Link/image definitions keyed by upper-cased identifier. */ + definitions: Map + /** Footnote definitions keyed by upper-cased identifier. */ + footnotes: Map +} + +/** + * Create an empty {@link ReferenceTargets}. + * @returns Fresh empty maps. + */ +export function createReferenceTargets(): ReferenceTargets { + return { definitions: new Map(), footnotes: new Map() } +} + +/** + * Record every definition and footnote definition under `nodes` into + * `targets`, depth-first, keeping the first definition per identifier. + * @param nodes - Subtrees to walk (top-level blocks or any nested children). + * @param targets - Accumulator, typically shared across incremental segments. + */ +export function collectReferenceTargets( + nodes: readonly Md.RootContent[], + targets: ReferenceTargets, +): void { + for (const node of nodes) { + if (node.type === 'definition') { + const id = node.identifier.toUpperCase() + if (!targets.definitions.has(id)) targets.definitions.set(id, node) + } else if (node.type === 'footnoteDefinition') { + const id = node.identifier.toUpperCase() + if (!targets.footnotes.has(id)) targets.footnotes.set(id, node) + } + if ('children' in node) collectReferenceTargets(node.children, targets) + } +} + +/** + * One render pass's state: immutable options and targets plus the footnote + * numbering accumulated in document order while references render. + */ +export interface MarkdownRenderContext { + /** Streaming arm: fences render plain and TeX stays literal. */ + readonly streaming: boolean + /** Localized fence copy-button labels. */ + readonly codeLabels: MarkdownCodeLabels | undefined + /** Reference targets visible to this pass. */ + readonly targets: ReferenceTargets + /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */ + readonly footnoteOrder: string[] + /** References rendered per identifier; drives the section's back-reference count. */ + readonly footnoteCounts: Map +} + +/** + * Render top-level blocks. Nodes that render nothing (definitions, unmapped + * types) are dropped rather than kept as null placeholders, matching the + * replaced pipeline's child lists so separator newlines land identically. + * @param blocks - Blocks with their stream-stable render keys. + * @param context - The pass state; footnote numbering mutates in document order. + * @returns One React node per rendered block. + */ +export function renderBlocks( + blocks: readonly PositionedBlock[], + context: MarkdownRenderContext, +): ReactNode[] { + return blocks + .map(block => renderNode(block.node, block.key, context)) + .filter(element => element !== null) +} + +/** + * Interleave the newline text nodes the replaced pipeline emitted between + * block-level children. They are invisible between elements but coalesce + * into adjacent literal raw-HTML text, where the DOM parity fixtures pin + * them. + * @param elements - Rendered block children with empty renders already dropped. + * @param edges - Also emit the leading and trailing newline (hast's loose wrap). + * @returns The interleaved children. + */ +export function wrapBlockChildren(elements: readonly ReactNode[], edges: boolean): ReactNode[] { + const wrapped: ReactNode[] = [] + for (const element of elements) { + if (edges || wrapped.length > 0) wrapped.push('\n') + wrapped.push(element) + } + if (edges && elements.length > 0) wrapped.push('\n') + return wrapped +} + +/** + * A block child rendered for a parent that must tell paragraphs apart from + * other blocks (list items unwrap them when tight; footnote bodies receive + * their back-references inside the trailing paragraph). + */ +type BlockEntry = { paragraph: ReactNode[] } | { element: ReactNode } + +/** Render container children into {@link BlockEntry} values, dropping empty renders. */ +function renderBlockEntries( + blocks: readonly Md.RootContent[], + context: MarkdownRenderContext, +): BlockEntry[] { + const entries: BlockEntry[] = [] + for (const [index, block] of blocks.entries()) { + if (block.type === 'paragraph') { + entries.push({ paragraph: renderChildren(block.children, context) }) + } else { + const element = renderNode(block, index, context) + if (element !== null) entries.push({ element }) + } + } + return entries +} + +function renderChildren( + nodes: readonly Md.RootContent[], + context: MarkdownRenderContext, +): ReactNode[] { + return nodes.map((node, index) => renderNode(node, index, context)) +} + +function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderContext): ReactNode { + switch (node.type) { + case 'text': + return node.value + case 'paragraph': + return

{renderChildren(node.children, context)}

+ case 'heading': + return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context)) + case 'blockquote': + return ( +
+ {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)} +
+ ) + case 'thematicBreak': + return
+ case 'break': + // The replaced pipeline emitted a newline text node after each
. + return
{'\n'}
+ case 'strong': + return {renderChildren(node.children, context)} + case 'emphasis': + return {renderChildren(node.children, context)} + case 'delete': + return {renderChildren(node.children, context)} + case 'inlineCode': + // Parity with mdast-util-to-hast: inline code renders line endings as spaces. + return {node.value.replace(/\r?\n|\r/g, ' ')} + case 'html': + // No HTML parser enters the pipeline: raw HTML stays literal text. + return node.value + case 'code': + return renderCode(node, key, context) + case 'math': + return {renderTexToReact(node.value, true)} + case 'inlineMath': + return {renderTexToReact(node.value, false)} + case 'list': + return renderList(node, key, context) + case 'listItem': + // Reachable only in hand-built trees: the grammar emits items inside lists. + return renderListItem(node, listItemLoose(node), key, context) + case 'table': + return renderTable(node, key, context) + case 'link': + return renderAnchor(node.url, renderChildren(node.children, context), key) + case 'linkReference': + return renderLinkReference(node, key, context) + case 'image': + return renderImage(node.url, node.alt ?? '', key) + case 'imageReference': + return renderImageReference(node, key, context) + case 'footnoteReference': + return renderFootnoteReference(node, key, context) + case 'definition': + case 'footnoteDefinition': + // Targets render elsewhere: definitions resolve references in place; + // footnote bodies render in the trailing section. + return null + default: + // Documented default for the merge-extensible union: node types without + // a mapping (tableRow/tableCell outside a table, frontmatter, future + // grammar contributions) render nothing. + return null + } +} + +function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode { + const language = node.lang ?? undefined + if (node.value === '') { + // Parity: the replaced pipeline kept the stock
 for an empty fence.
+    return (
+      
+        
+      
+ ) + } + // The replaced pipeline recovered the grammar id from the hast class with + // /language-([\w-]+)/, which truncates at the first non-word character. + const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0] + if (!context.streaming && lang === 'math') { + // ```math fences render as display TeX once settled (rehype-katex parity); + // its text extraction saw the code block's trailing newline. + return {renderTexToReact(`${node.value}\n`, true)} + } + return ( + + ) +} + +/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */ +function listLoose(list: Md.List): boolean { + return (list.spread ?? false) || list.children.some(listItemLoose) +} + +function listItemLoose(item: Md.ListItem): boolean { + return item.spread ?? item.children.length > 1 +} + +function renderList(node: Md.List, key: Key, context: MarkdownRenderContext): ReactNode { + const loose = listLoose(node) + const properties: { start?: number; className?: string } = {} + if (typeof node.start === 'number' && node.start !== 1) properties.start = node.start + if (node.children.some(item => typeof item.checked === 'boolean')) { + properties.className = 'contains-task-list' + } + return createElement( + node.ordered === true ? 'ol' : 'ul', + { key, ...properties }, + ...node.children.map((item, index) => renderListItem(item, loose, index, context)), + ) +} + +function renderListItem( + item: Md.ListItem, + loose: boolean, + key: Key, + context: MarkdownRenderContext, +): ReactNode { + const entries = renderBlockEntries(item.children, context) + const task = typeof item.checked === 'boolean' + if (task) { + const checkbox = + const head = entries[0] + if (head !== undefined && 'paragraph' in head) { + head.paragraph = head.paragraph.length > 0 ? [checkbox, ' ', ...head.paragraph] : [checkbox] + } else { + entries.unshift({ paragraph: [checkbox] }) + } + } + // Newline placement and tight-paragraph unwrapping mirror + // mdast-util-to-hast's list-item handler: a newline before every child + // except a tight leading paragraph, and after a trailing non-paragraph + // (or any trailing child when loose). + const parts: ReactNode[] = [] + for (const [index, entry] of entries.entries()) { + const isParagraph = 'paragraph' in entry + if (loose || index !== 0 || !isParagraph) parts.push('\n') + if (!isParagraph) parts.push(entry.element) + else if (loose) parts.push(

{entry.paragraph}

) + else parts.push({entry.paragraph}) + } + const tail = entries[entries.length - 1] + if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n') + return ( +
  • + {parts} +
  • + ) +} + +function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode { + const align = node.align ?? null + const [headRow, ...bodyRows] = node.children + return ( +
    + + {headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}} + {bodyRows.length > 0 && ( + + {bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))} + + )} +
    +
    + ) +} + +function renderTableRow( + row: Md.TableRow, + cellTag: 'th' | 'td', + align: readonly Md.AlignType[] | null, + key: Key, + context: MarkdownRenderContext, +): ReactNode { + // With column alignment present, every row renders exactly one cell per + // column, padding or truncating the row (mdast-util-to-hast parity). + const length = align === null ? row.children.length : align.length + const cells: ReactNode[] = [] + for (let index = 0; index < length; index++) { + const cell = row.children[index] + const alignValue = align?.[index] + cells.push(createElement( + cellTag, + // hast-util-to-jsx-runtime's default tableCellAlignToStyle turned the + // deprecated align attribute into an inline style; keep that DOM. + { key: index, style: alignValue == null ? undefined : { textAlign: alignValue } }, + ...(cell === undefined ? [] : renderChildren(cell.children, context)), + )) + } + return {cells} +} + +function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode { + const safeHref = sanitizeUrl(normalizeUri(url)) + if (safeHref === '') return {children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) +} + +function renderImage(url: string, alt: string, key: Key): ReactNode { + const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url))) + if (imageSrc === undefined) { + return {alt} + } + return ( + {alt} + ) +} + +/** The bracketed source text a reference reverts to when its definition is missing. */ +function referenceSuffix(node: Md.LinkReference | Md.ImageReference): string { + if (node.referenceType === 'collapsed') return '][]' + if (node.referenceType === 'full') return `][${node.label ?? node.identifier}]` + return ']' +} + +function renderLinkReference( + node: Md.LinkReference, + key: Key, + context: MarkdownRenderContext, +): ReactNode { + const definition = context.targets.definitions.get(node.identifier.toUpperCase()) + const children = renderChildren(node.children, context) + if (definition === undefined) { + // The grammar only emits references whose definitions exist somewhere in + // the same parse, but incremental segments and hand-built trees may still + // present unresolved ones: revert to the bracketed source text. + return {'['}{children}{referenceSuffix(node)} + } + return renderAnchor(definition.url, children, key) +} + +function renderImageReference( + node: Md.ImageReference, + key: Key, + context: MarkdownRenderContext, +): ReactNode { + const definition = context.targets.definitions.get(node.identifier.toUpperCase()) + if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}` + return renderImage(definition.url, node.alt ?? '', key) +} + +function renderFootnoteReference( + node: Md.FootnoteReference, + key: Key, + context: MarkdownRenderContext, +): ReactNode { + const id = node.identifier.toUpperCase() + const seen = context.footnoteCounts.get(id) + if (seen === undefined) context.footnoteOrder.push(id) + context.footnoteCounts.set(id, (seen ?? 0) + 1) + // The in-page anchor fails the protocol allowlist, so only the numbered + // superscript renders (matching the replaced pipeline's unwrapped link). + return {String(context.footnoteOrder.indexOf(id) + 1)} +} + +/** + * Render the trailing footnote section for every footnote referenced during + * the pass, in first-reference order, with one plain-text back-reference + * marker per rendered reference. + * @param context - The pass state after all blocks rendered. + * @returns The section, or null when no referenced footnote has a definition. + */ +export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode | null { + const items: ReactNode[] = [] + for (const id of context.footnoteOrder) { + const definition = context.targets.footnotes.get(id) + if (definition === undefined) continue + const count = context.footnoteCounts.get(id) ?? 0 + const backrefs: ReactNode[] = [] + for (let reference = 1; reference <= count; reference++) { + if (backrefs.length > 0) backrefs.push(' ') + backrefs.push('↩') + if (reference > 1) backrefs.push({String(reference)}) + } + const entries = renderBlockEntries(definition.children, context) + const tail = entries[entries.length - 1] + const body: ReactNode[] = entries.map((entry, index) => ( + 'paragraph' in entry + ? ( +

    + {entry.paragraph} + {entry === tail && <>{' '}{backrefs}} +

    + ) + : entry.element + )) + // Without a trailing paragraph the back-references join the block list + // itself (and pick up the wrap newlines), as in the replaced pipeline. + if (tail === undefined || !('paragraph' in tail)) body.push(...backrefs) + items.push( +
  • + {wrapBlockChildren(body, true)} +
  • , + ) + } + if (items.length === 0) return null + return ( +
    +

    Footnotes

    +
      {items}
    +
    + ) +} diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt new file mode 100644 index 0000000000..d213078f94 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt @@ -0,0 +1,12 @@ +
    +
    +

    + #text "level one\nstill one" +

    +

    + #text "nested" +