Przeglądaj źródła

refactor(llm): keep diagnostics in configurable provider metadata

Yichen Jiang 1 miesiąc temu
rodzic
commit
5bc792f629

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.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/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
-2026-09-07-pi-ai-settings-catalog-recovery.md: 1cf4fd1d2c062f79e5b98981d8fece919580f55c
-2026-09-07-pi-ai-settings-catalog-recovery.zh.md: e2190bbd78875234a2818ebf0851de0a163659e7
+2026-09-07-pi-ai-settings-catalog-recovery.md: 13c23233e924b6ba2e0b22381e791c5ccdcdc254
+2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 6f001f545108aa2006491127318ae3123722364f

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md

@@ -16,7 +16,7 @@ Initial profile resolution retains catalog diagnostics, while schema and self-co
 
 Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
 
-`LlmProviderInfo.configurationError` carries the first diagnostic for the provider row. Registration facts include that diagnostic so configuration repair refreshes the browser. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
+`LlmConfigurableProvider.error` carries the first diagnostic for the provider row. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
 
 This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale.
 
@@ -32,7 +32,7 @@ This extends the [provider-routed adapter decision](../architecture/2026-07-14-p
 
 ## Consequences
 
-Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. No settings API, storage format, or session event is added; provider metadata gains one optional diagnostic field.
+Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field.
 
 ## Testing
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md

@@ -16,7 +16,7 @@ pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期
 
 Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
 
-`LlmProviderInfo.configurationError` 为提供方行携带首个诊断。注册事实包含该诊断,因此修复配置会刷新浏览器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
+`LlmConfigurableProvider.error` 为提供方行携带首个诊断。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
 
 本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。
 
@@ -32,7 +32,7 @@ Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆
 
 ## Consequences
 
-依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。不增加 Settings API、存储格式或 session 事件;提供方元数据新增一个可选诊断字段。
+依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。
 
 ## Testing
 

+ 2 - 2
docs/subsystems/llm-streaming.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/subsystems/llm-streaming.md
-llm-streaming.md: 8544517517cb7c1da4ee4afaaa881eebab8063c4
-llm-streaming.zh.md: 0036939d0a8703d38bca77918cba62861e589bee
+llm-streaming.md: 655bd51a03975388711fbed430660f47f43842c0
+llm-streaming.zh.md: 71106f55d130f2d99a12acfe27d807e4f6af1eb3

+ 2 - 2
docs/subsystems/llm-streaming.md

@@ -459,8 +459,6 @@ interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
-  /** Configuration diagnostic; the route stays registered for inspection and repair. */
-  configurationError?: string
 }
 ```
 
@@ -494,6 +492,8 @@ interface LlmConfigurableProvider {
    * from outside.
    */
   declared?: boolean
+  /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
+  error?: string
 }
 ```
 

+ 2 - 2
docs/subsystems/llm-streaming.zh.md

@@ -465,8 +465,6 @@ interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
-  /** Configuration diagnostic; the route stays registered for inspection and repair. */
-  configurationError?: string
 }
 ```
 
@@ -500,6 +498,8 @@ interface LlmConfigurableProvider {
    * from outside.
    */
   declared?: boolean
+  /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
+  error?: string
 }
 ```
 

+ 4 - 4
packages/client/ui-settings-models/src/client/ModelsSection.tsx

@@ -323,15 +323,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
           const namespace = state.namespaces.get(target.settingsNs)
           /* v8 ignore next -- the join marks a row configured only when its namespace resolved */
           if (namespace === undefined) return null
-          const configurationError = row.entry.configurationError === undefined
+          const error = row.entry.error === undefined
             ? null
-            : <p role="alert" className={styles['error']}>{row.entry.configurationError}</p>
+            : <p role="alert" className={styles['error']}>{row.entry.error}</p>
           if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
             // First-run posture: the provider exists but has no key — the
             // setup card IS its presence on the page, until the user closes it.
             return (
               <li key={row.entry.provider} className={styles['setupCard']}>
-                {configurationError}
+                {error}
                 {renderProviderEditor({
                   target,
                   namespace,
@@ -421,7 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                     : null}
                 </span>
               </div>
-              {configurationError}
+              {error}
               {renderSlot(
                 'settings.models.provider-card',
                 { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },

+ 10 - 15
packages/client/ui-settings-models/src/client/store.ts

@@ -30,7 +30,7 @@ export interface ProviderDirectoryEntry {
   readonly settingsPath: readonly string[]
   readonly active: boolean
   readonly declared?: boolean
-  readonly configurationError?: string
+  readonly error?: string
 }
 
 /**
@@ -44,20 +44,16 @@ export function joinProviderDirectory(
   directory: readonly LlmConfigurableProvider[],
 ): ProviderDirectoryEntry[] {
   const active = new Set(registered.map(provider => provider.id))
-  const errors = new Map(registered.map(provider => [provider.id, provider.configurationError]))
   const declared = new Set(directory.map(entry => entry.provider))
-  const rows: ProviderDirectoryEntry[] = directory.map((entry) => {
-    const configurationError = errors.get(entry.provider)
-    return {
-      provider: entry.provider,
-      displayName: entry.displayName,
-      settingsNs: entry.settingsNs,
-      settingsPath: [...entry.settingsPath],
-      active: active.has(entry.provider),
-      ...entry.declared === undefined ? {} : { declared: entry.declared },
-      ...configurationError === undefined ? {} : { configurationError },
-    }
-  })
+  const rows: ProviderDirectoryEntry[] = directory.map(entry => ({
+    provider: entry.provider,
+    displayName: entry.displayName,
+    settingsNs: entry.settingsNs,
+    settingsPath: [...entry.settingsPath],
+    active: active.has(entry.provider),
+    ...entry.declared === undefined ? {} : { declared: entry.declared },
+    ...entry.error === undefined ? {} : { error: entry.error },
+  }))
   for (const provider of registered) {
     if (declared.has(provider.id)) continue
     rows.push({
@@ -66,7 +62,6 @@ export function joinProviderDirectory(
       settingsNs: '',
       settingsPath: [],
       active: true,
-      ...provider.configurationError === undefined ? {} : { configurationError: provider.configurationError },
     })
   }
   return rows

+ 2 - 2
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -331,8 +331,8 @@ describe('ModelsSection', () => {
   it('shows a catalog diagnostic while keeping the provider editable', async () => {
     const scripted = scriptedFace()
     const failure = 'llm-pi-ai: provider "openai" model "111" needs an api'
-    scripted.face.llm.listProviders.mockResolvedValue(remoteOk([
-      { id: 'openai', name: 'openai', configurationError: failure },
+    scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
+      { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], error: failure },
     ]))
     await mountFace(scripted)
     expect(screen.getByRole('alert').textContent).toBe(failure)

+ 8 - 6
packages/client/ui-settings-models/tests/store.client.spec.ts

@@ -6,12 +6,14 @@ import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/
 import { settingsSchema } from './settings-schema.client.ts'
 import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts'
 
-it('retains diagnostics for a live provider without a configurable directory entry', () => {
-  expect(joinProviderDirectory([{ id: 'orphan', name: 'Orphan', configurationError: 'catalog unavailable' }], []))
-    .toEqual([{
-      provider: 'orphan', displayName: 'Orphan', settingsNs: '', settingsPath: [], active: true,
-      configurationError: 'catalog unavailable',
-    }])
+it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => {
+  expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{
+    provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'],
+    error: 'catalog unavailable',
+  }])).toEqual([{
+    provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'],
+    active, error: 'catalog unavailable',
+  }])
 })
 
 let nextRpc = 0

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

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

+ 2 - 2
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: 9ec589315c40ef1a4de90b860f13454da783a2a9
-README.zh.md: 22bde6757fba0dabf60d66751283dd261f5854da
+README.md: 799c99386443922bd8d4e24d8f84a826d18ff67e
+README.zh.md: 8aecbc840725e02e565f2b228444d03565d931e4

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -114,7 +114,7 @@ The plugin answers "which models can this provider serve?" for a route a configu
 
 A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` list; an unserviceable profile is refused where it is written, naming the route and model. Failures carry stable codes: a credential that cannot be used fails with `INVALID_CREDENTIAL` naming the route and reference, a route whose `apiKeyEnv` reference resolves to nothing fails with `MISSING_CREDENTIAL`, an unconfigured model fails with `UNKNOWN_MODEL`, and terminal provider failures distinguish `QUOTA` from transient `RATE_LIMIT`. `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers.
 
-Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmProviderInfo.configurationError`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure.
+Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmConfigurableProvider.error`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure.
 
 -----
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -114,7 +114,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的
 
 pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无法服务的 profile 会在写入处被拒绝,并点名路由与模型。失败携带稳定 code:无法使用的凭据以 `INVALID_CREDENTIAL` 失败并点名路由与引用,`apiKeyEnv` 引用解析为空的路由以 `MISSING_CREDENTIAL` 失败,未配置模型以 `UNKNOWN_MODEL` 失败,终止性提供方失败则区分 `QUOTA` 与暂时性 `RATE_LIMIT`。`GenerateOptions.stop` 以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式 UI 无法跨提供方保证它。
 
-Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmProviderInfo.configurationError` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。
+Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。
 
 -----
 

+ 1 - 6
packages/llm/llm-pi-ai/src/adapter.ts

@@ -266,12 +266,7 @@ export class PiAiAdapter extends LlmAdapter {
     // 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.
-    const profile = this.current().profiles.get(provider)
-    return {
-      id: provider,
-      name: profile?.displayName ?? provider,
-      ...profile?.catalogError === undefined ? {} : { configurationError: profile.catalogError },
-    }
+    return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider }
   }
 
   override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {

+ 3 - 3
packages/llm/llm-pi-ai/src/index.ts

@@ -106,7 +106,6 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
       provider,
       displayName: profile.displayName,
       retryPolicy: profile.retryPolicy,
-      catalogError: profile.catalogError,
     }))
     .sort((left, right) => left.provider.localeCompare(right.provider))
 }
@@ -124,7 +123,7 @@ function directoryEntries(
 ): LlmConfigurableProvider[] {
   const catalog = new Set(catalogProviderIds())
   const entries = new Map<string, LlmConfigurableProvider>()
-  const declare = (provider: string, displayName: string): void => {
+  const declare = (provider: string, displayName: string, error?: string): void => {
     entries.set(provider, {
       provider,
       displayName,
@@ -134,10 +133,11 @@ function directoryEntries(
       // narrowing a shipped provider's models stores a profile too, and that
       // route is still one pi-ai knows.
       declared: !catalog.has(provider),
+      ...error === undefined ? {} : { error },
     })
   }
   for (const provider of catalog) declare(provider, provider)
-  for (const [provider, profile] of profiles) declare(provider, profile.displayName)
+  for (const [provider, profile] of profiles) declare(provider, profile.displayName, profile.catalogError)
   return [...entries.values()]
 }
 

+ 16 - 5
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts

@@ -113,7 +113,11 @@ describe('request-level dynamic profiles', () => {
       + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
 
     expect(ctx.settings.describe().map(section => section.ns)).toContain(NS)
-    expect(ctx.llm.listProviders()).toContainEqual({ id: 'openrouter', name: 'openrouter', configurationError: failure })
+    expect(ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }])
+    expect(ctx.llm.listConfigurableProviders()).toContainEqual({
+      provider: 'openrouter', displayName: 'openrouter', settingsNs: NS,
+      settingsPath: ['providers', 'openrouter'], declared: false, error: failure,
+    })
     expect(await readFile(path, 'utf8')).toBe(stored)
     expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual([known.id])
     const bad = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] })
@@ -128,7 +132,12 @@ describe('request-level dynamic profiles', () => {
     await expect(ctx.settings.update(NS, { providers: { openrouter: { displayName: 'Edited' } } })).rejects.toThrow(failure)
     expect(await readFile(path, 'utf8')).toBe(beforeRejected)
 
+    const diagnostics: Array<string | undefined> = []
+    ctx.on('llm/adapters-updated', () => {
+      diagnostics.push(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'openrouter')?.error)
+    })
     await ctx.settings.mutate(NS, [{ op: 'set', path: ['providers', 'openrouter', 'api'], value: 'openai-completions' }])
+    expect(diagnostics).toEqual([undefined])
     expect(ctx.llm.listProviders()[0]).toEqual({ id: 'openrouter', name: 'openrouter' })
     const repaired = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] })
     expect(repaired.message.content).toEqual([{ type: 'text', text: 'hello' }])
@@ -143,16 +152,18 @@ describe('request-level dynamic profiles', () => {
     } } }))
     const ctx = await boot(dir, {})
     expect(ctx.settings.describe().map(section => section.ns)).toContain(NS)
-    expect(ctx.llm.listProviders().map(provider => provider.configurationError)).toEqual([
-      expect.stringContaining('modelOverrides names "removed-model"'),
-      expect.stringContaining('resolves no models'),
-    ])
+    expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error)
+      .toContain('modelOverrides names "removed-model"')
+    expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')?.error)
+      .toContain('resolves no models')
     expect((await ctx.llm.listModels('anthropic')).length).toBeGreaterThan(0)
     await expect(ctx.llm.resolveModelInfo('anthropic', 'removed-model')).rejects.toThrow('modelOverrides names "removed-model"')
     await expect(ctx.llm.resolveModelInfo('retired-route', 'anything')).rejects.toThrow('resolves no models')
     await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'retired-route'] }])
     await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'anthropic', 'modelOverrides', 'removed-model'] }])
     expect(ctx.llm.listProviders()).toEqual([{ id: 'anthropic', name: 'anthropic' }])
+    expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')).toBeUndefined()
+    expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error).toBeUndefined()
   })
 
   it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {

+ 2 - 2
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: a0f79106765e17437cf6bfd2cbf91e2b0cef34a9
-README.zh.md: fe99f7680e07f1742869c12692668ae426083d60
+README.md: 5fe789f38caf8e2289930ff598868ddab69d5f76
+README.zh.md: 8fb48b01e0c7a888b48f65d189279a8e0f684264

+ 2 - 2
packages/llm/llm/README.md

@@ -54,13 +54,13 @@ for await (const chunk of ctx.llm.stream({
 }
 ```
 
-After a successful mount, `ctx.llm.listProviders()` reports the registered routes in registration order. Adapters may include `configurationError` in provider metadata to keep a failed configuration visible for repair; this diagnostic does not unregister its route.
+After a successful mount, `ctx.llm.listProviders()` reports the registered routes in registration order.
 
 ### What you can do
 
 - **Stream one model call** — `ctx.llm.stream(options)` yields raw chunks (token-level deltas) for any registered provider and model; consumers assemble them with `BlockAssembler`.
 - **Register provider adapters** — an adapter owns one or more provider routes, and its registration captures that route's retry policy; registering the same route twice fails with `DUPLICATE_ADAPTER`.
-- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart.
+- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. `LlmConfigurableProvider.error` reports a configuration diagnostic for repair; unaffected models can remain serviceable.
 - **Discover and resolve models** — list the models an adapter advertises, interrogate an endpoint for the models it serves, and resolve one exact model's context window, output default, reasoning efforts, and input modalities.
 - **Validate call config** — an explicit or configured reasoning effort is checked against the exact model before any provider I/O, and an adapter-configured output cap is materialized when the request omits one.
 - **Read an embedded Assistant stream without expanding it** — `assistantStreamFirstTokenTime` (first token), `assistantStreamHasVisibleContent` (any visible content), and `assistantStreamHasVisibleText` (any visible text) answer their questions from the compact records with early exit; `lastAssistantStreamChunk` scans backward to the last raw chunk of one type, `assistantStreamChunks` and `joinAssistantStreamText` scan the whole stream, and `assembleAssistantStream` feeds a `BlockAssembler` one joined delta per run with the same blocks, usage, and replay state as the per-member expansion. `runFirstTokenTime` and `runFirstVisibleTime` do the early-exit scan for one packed run, and `isTokenDelta`, `isVisibleChunk`, and `chunkHasVisibleText` define the token and visibility rules for a single chunk. `expandAssistantStream` remains the validating path for records read at a durable boundary; it is not memoized, because a retained expansion costs roughly ten times the compact stream for as long as the event lives.

+ 2 - 2
packages/llm/llm/README.zh.md

@@ -54,13 +54,13 @@ for await (const chunk of ctx.llm.stream({
 }
 ```
 
-挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。适配器可以在提供方元数据中携带 `configurationError`,保留错误配置的可见性以供修复;此诊断不会注销路由。
+挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。
 
 ### 你可以做什么
 
 - **流式发起一次模型调用**——`ctx.llm.stream(options)` 为任何已注册提供方与模型产出原始分片(token 级增量);消费方用 `BlockAssembler` 组装。
 - **注册提供方适配器**——一个适配器拥有一个或多个提供方路由,其注册会捕获该路由的重试策略;重复注册同一路由会以 `DUPLICATE_ADAPTER` 失败。
-- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接事实,无需重启。
+- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接事实,无需重启。`LlmConfigurableProvider.error` 报告供修复的配置诊断;未受影响的模型仍可提供服务。
 - **发现与解析模型**——列出适配器公布的模型、询问端点它提供哪些模型,并解析某个精确模型的上下文窗口、输出默认值、推理(reasoning)强度与输入模态。
 - **校验调用配置**——显式或配置的推理强度会在任何提供方 I/O 之前对照精确模型校验;请求省略输出上限时,会填入适配器配置的输出上限。
 - **不展开即读取内嵌 Assistant 流**——`assistantStreamFirstTokenTime`(首 token)、`assistantStreamHasVisibleContent`(任一可见内容)与 `assistantStreamHasVisibleText`(任一可见文本)以提前退出从紧凑记录回答各自的问题;`lastAssistantStreamChunk` 反向扫描到某一类型的最后一个原始 chunk,`assistantStreamChunks` 与 `joinAssistantStreamText` 扫描整个流,`assembleAssistantStream` 向 `BlockAssembler` 每个 run 喂一段拼接 delta,blocks/usage/replayState 与逐成员展开相同。`runFirstTokenTime` 与 `runFirstVisibleTime` 对单个打包 run 做提前退出扫描,`isTokenDelta`、`isVisibleChunk` 与 `chunkHasVisibleText` 定义单个 chunk 的 token 与可见性规则。`expandAssistantStream` 仍是持久边界读取记录的校验路径;它不被记忆化,因为保留的展开在事件生命周期内约花费紧凑流的十倍内存。

+ 1 - 4
packages/llm/llm/src/index.ts

@@ -434,10 +434,7 @@ export class LlmRuntime extends TypertRemoteService {
         ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
       registrations.push({
         adapter,
-        provider: {
-          id: info.id, name: info.name,
-          ...info.configurationError === undefined ? {} : { configurationError: info.configurationError },
-        },
+        provider: { id: info.id, name: info.name },
         retryPolicy,
       })
     }

+ 2 - 2
packages/llm/llm/src/types.ts

@@ -198,8 +198,6 @@ export interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
-  /** Configuration diagnostic; the route stays registered for inspection and repair. */
-  configurationError?: string
 }
 
 /** Merge-extensible provider model modality vocabulary. */
@@ -238,6 +236,8 @@ export interface LlmConfigurableProvider {
    * from outside.
    */
   declared?: boolean
+  /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
+  error?: string
 }
 
 /**

+ 0 - 1
packages/llm/llm/tests/service.spec.ts

@@ -266,7 +266,6 @@ describe('LlmRuntime', () => {
       expect(projected, fixture.name).toMatchObject({ type: 'text' })
       if (projected?.type !== 'text') throw new Error(`expected projected text for ${fixture.name}`)
       expect(projected.text, fixture.name).toContain(fixture.expected)
-      expect(ctx.llm.fileRequestText(attachment), fixture.name).toBe(projected.text)
       if (fixture.fs !== undefined) {
         expect(projected.text, fixture.name).toContain('include this saved path in the delegation prompt')
       }