fix: address ds-review-bot v8 findings

- llm-deepseek: the uncatalogued resolveModel fallback declares text-only
  modalities — the wire route is text-only regardless of catalog
  membership, so "unknown" must not let the host persist-then-fail images.
- session.selectModel also consults the pending-inbox mirror: a queued
  image prompt enters the log only when claimed, after a switch would land.
- attachment store: ensureDurableDirectory syncs every ancestor entry up to
  a caller-vouched boundary regardless of what mkdir reports — a raced
  "already existed" is not "already durable".
- One image walker (imageBlockIn/imageInEvent) now serves both attachment
  authorization and the selection gate; referencedImage therefore also
  authorizes references inside wrapped message content.
- InputHub: the scope disposer resolves the conversation service optionally
  (teardown/HMR must reach quiescence), and a send failing after its scope
  died releases the in-flight drafts instead of restoring them onto a
  disposed shell.
- http-bridge destroys declared-oversize requests with connection: close
  instead of draining a body the client can trickle indefinitely.
- LlmService validates AND detaches modality arrays identically on the
  advisory and exact routes; READMEs record the fourth INVALID_MODEL_INFO
  rejection reason.
- CLI provider docs (JSDoc, README pair, Agent Note pair) describe the
  reuse behavior; llm-route.spec now parses the SHIPPED cordis.yml through
  the production extraction, pinning the row coupling.
- image-display lane pins gallery/rail shape in inline snapshots and the
  object-URL scheme this environment must take; stale host.schema comment
  dropped.
This commit is contained in:
creatixchu
2026-07-29 19:40:17 +08:00
parent adce3b833d
commit f73bf425ec
27 changed files with 337 additions and 100 deletions
@@ -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-22-web-multimodal-image-input-and-durable-attachments.md
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 2b676c1965dbef0bdc24ba6d5b4af5bf66840780
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f13648051bcf4c81c8b032897645c9b6aeb0d32a
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e9d7f8ebe78aa76285367e83374a6ace3a9eef21
@@ -122,7 +122,7 @@ Model catalog entries gain optional merge-extensible input and output modality d
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host.
The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider <name> --model <id>`, which mounts that pi-ai catalog route with the provider's ambient credentials; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider <name> --model <id>`: the yml pi-ai row already routes openai/anthropic with ambient credentials, and only a catalog provider absent from that row is mounted dynamically; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
@@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。
Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider <name> --model <id>` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider <name> --model <id>` 到达这条路径yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 98b09f7da745d152e90a4ad84ee1639b20ab295b
README.zh.md: c8d632c63868dd1906c9bce05c88134ea98104a2
README.md: 337370f6b3738cef22bc826501633b9aca1f8c62
README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711
+1 -1
View File
@@ -14,7 +14,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` mounts that pi-ai catalog route with provider-native ambient credentials; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` selects that route: for the shipped roster (openai/anthropic) the already-mounted yml pi-ai row serves it with provider-native ambient credentials, while a pi-ai catalog provider absent from that row is mounted dynamically; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
+1 -1
View File
@@ -14,7 +14,7 @@ TUI 界面:
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider <name> --model <id>` 会挂载对应的 pi-ai catalog 路由,并使用提供方原生环境凭据;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider <name> --model <id>` 选择对应路由:出货清单内的 provideropenai/anthropic)由 yml 中已挂载的 pi-ai row 以提供方原生环境凭据直接服务,只有该 row 之外的 pi-ai catalog provider 才会动态挂载;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
+35 -12
View File
@@ -116,6 +116,34 @@ export function resolveLlmRoute(input: LlmRouteInput): LlmRoute {
}
}
/**
* Bypass parse of an include yml's top-level entry rows (id → row). Exported
* so tests can pin the shipped tree's real row coupling instead of literals.
* @param configPath - absolute path of the include cordis.yml.
* @returns row map keyed by entry id.
*/
export function parseIncludeYmlRows(configPath: string): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
return rows
}
/**
* Providers the yml's static pi-ai row routes — the roster {@link resolveLlmRoute} reuses.
* @param rows - parsed include rows.
* @returns provider ids in row order (empty when the row is absent).
*/
export function ymlPiAiProvidersOf(rows: ReadonlyMap<string, { config?: unknown }>): string[] {
const config = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined
return (config?.providers ?? [])
.map(entry => entry.provider)
.filter((value): value is string => typeof value === 'string')
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -170,7 +198,11 @@ export interface AppCLIEntryOptions {
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Host default provider override. Non-DeepSeek routes mount pi-ai with ambient credentials. */
/**
* Host default provider override. Providers the shipped yml pi-ai row
* already routes are reused; only a provider absent from that row mounts
* pi-ai dynamically.
*/
provider?: string
/** Host default model override. */
model?: string
@@ -262,14 +294,11 @@ export class AppCLIEntry {
if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model)
const gatewayConfig = rows.get('api-gateway')?.config as Record<string, unknown> | undefined
const piAiRow = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined
const route = resolveLlmRoute({
cli: { provider: this.options.provider, model: this.options.model },
profile: { provider: profile.provider, model: profile.model },
gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model },
ymlPiAiProviders: (piAiRow?.providers ?? [])
.map(p => p.provider)
.filter((value): value is string => typeof value === 'string'),
ymlPiAiProviders: ymlPiAiProvidersOf(rows),
})
this.piAiProvider = route.dynamicPiAiProvider
@@ -344,13 +373,7 @@ export class AppCLIEntry {
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
return rows
return parseIncludeYmlRows(this.options.configPath)
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
+16 -1
View File
@@ -1,6 +1,7 @@
/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveLlmRoute } from '../src/app-cli-entry.ts'
import { parseIncludeYmlRows, resolveLlmRoute, ymlPiAiProvidersOf } from '../src/app-cli-entry.ts'
/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */
const SHIPPED = {
@@ -52,6 +53,20 @@ describe('resolveLlmRoute', () => {
})).toThrow(/provider anthropic requires an explicit model/)
})
it('reuses the SHIPPED cordis.yml roster — the coupling that prevents the duplicate-adapter boot failure', () => {
// Parsed from the real file through the production extraction, not a
// literal roster: renaming the `llm-pi-ai` row or its providers field
// must fail here, because composePatches reads exactly these shapes.
const rows = parseIncludeYmlRows(join(import.meta.dirname, '..', 'cordis.yml'))
const roster = ymlPiAiProvidersOf(rows)
expect(roster).toEqual(['openai', 'anthropic'])
const gateway = (rows.get('api-gateway')?.config ?? {}) as { provider?: unknown; model?: unknown }
expect(resolveLlmRoute({
cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {},
gateway, ymlPiAiProviders: roster,
})).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined })
})
it('fails loud on a missing or empty provider', () => {
expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] }))
.toThrow(/provider must be a non-empty string/)
+36 -10
View File
@@ -120,24 +120,41 @@ it('renders the history image pair through the authorized attachment route and o
await openFixtureSession()
// Both the user-side (align=end) and assistant-side (align=start) galleries
// load real fixture bytes over sessions.attachment (data: fallback in jsdom).
// load real fixture bytes over sessions.attachment. jsdom provides
// createObjectURL, so this environment MUST take the object-URL path — a
// data: src here would mean the fallback ran where it should not.
await waitFor(() => {
const user = document.querySelector('[data-align="end"] img')
const assistant = document.querySelector('[data-align="start"] img')
if (user === null || assistant === null) throw new Error('history image galleries missing')
// jsdom serves object URLs; environments without createObjectURL fall back to data:.
expect(user.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/)
expect(assistant.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/)
if (document.querySelector('[data-align="end"] img') === null
|| document.querySelector('[data-align="start"] img') === null) {
throw new Error('history image galleries missing')
}
}, { timeout: 10_000 })
const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)]
.map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] }))
expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(`
{
"assistant": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
"user": [
{
"alt": "fixture-image.png",
"scheme": "blob",
},
],
}
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
expect(userImage.getAttribute('alt')).toBe('fixture-image.png')
// Double-click opens the original-size lightbox; Escape/close dismisses it.
const frame = userImage.closest('button')
if (frame === null) throw new Error('image frame button missing')
fireEvent.doubleClick(frame)
const lightbox = await screen.findByRole('dialog')
expect(within(lightbox).getByRole('img').getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/)
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull()
@@ -166,7 +183,16 @@ it('accepts a pasted image into the composer rail and removes it', async () => {
if (el === null) throw new Error('attachment rail missing')
return el
}, { timeout: 5_000 })
expect(rail.querySelector('img')?.getAttribute('src')).toMatch(/^(blob:|data:)/)
expect([...rail.querySelectorAll('img')].map(img => ({
alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0],
}))).toMatchInlineSnapshot(`
[
{
"alt": "pasted.png",
"scheme": "blob",
},
]
`)
const remove = rail.querySelector('button[aria-label^="移除图片"]')
if (remove === null) throw new Error('remove button missing')
+1 -1
View File
@@ -550,7 +550,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:58`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:59`](../../packages/llm/llm/src/index.ts)
## `session/*`
+1 -1
View File
@@ -852,7 +852,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../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:191`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:192`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
+1 -1
View File
@@ -30,7 +30,7 @@ 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`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`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/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../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/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../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), [`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), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
@@ -79,21 +79,28 @@ async function syncDirectory(path: string): Promise<void> {
}
/**
* Create one private directory tree and persist every newly published ancestor.
* Create one private directory tree and persist every ancestor entry up to a
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
* reports as newly created: a concurrent first save can create a level this
* process then merely observes, so "already existed" is not "already durable"
* — the entry may still be unsynced in the creator, and a crash would drop a
* directory the session checkpoint already references. Re-syncing a durable
* entry is harmless; skipping an unsynced one is not.
* @param path - absolute directory to create.
* @param boundary - absolute ancestor the caller vouches is already durable.
*/
async function ensureDurableDirectory(path: string): Promise<void> {
async function ensureDurableDirectory(path: string, boundary: string): Promise<void> {
const target = resolve(path)
const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 })
const stop = resolve(boundary)
await mkdir(target, { recursive: true, mode: 0o700 })
await chmod(target, 0o700)
if (firstCreated === undefined) return
const highestCreated = resolve(firstCreated)
let created = target
while (true) {
await syncDirectory(dirname(created))
if (created === highestCreated) return
created = dirname(created)
let level = target
while (level !== stop) {
const parent = dirname(level)
await syncDirectory(parent)
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
if (parent === level) return
level = parent
}
}
@@ -110,8 +117,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
const sha256 = digest(input.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
await ensureDurableDirectory(bucket)
await ensureDurableDirectory(staging)
// The durable boundary is the root's grandparent (DSH_HOME for the
// documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be
// first-created by a concurrent save, so their entries sync on every path.
const boundary = dirname(dirname(resolve(root)))
await ensureDurableDirectory(bucket, boundary)
await ensureDurableDirectory(staging, boundary)
const temporary = join(staging, randomUUID())
const target = objectPath(root, sha256)
let handle
@@ -47,7 +47,7 @@ afterEach(async () => {
})
describe('local attachment store', () => {
it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => {
it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => {
const storageRoot = await root()
const base = join(storageRoot, '..', '..')
const sha256 = createHash('sha256').update(PNG).digest('hex')
@@ -57,12 +57,20 @@ describe('local attachment store', () => {
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
// Every level between each created directory and the vouched boundary
// syncs unconditionally — "already existed" is not "already durable"
// when a concurrent first save may have created but not yet synced it.
expect(fsControl.syncedDirectories).toEqual([
// bucket chain: every parent entry between the bucket and the boundary.
objects,
storageRoot,
join(storageRoot, '..'),
base,
// staging chain re-walks the shared ancestors after creating tmp.
storageRoot,
join(storageRoot, '..'),
base,
// publication: the settled object's bucket and its parent for the rename.
bucket,
objects,
])
@@ -30,9 +30,13 @@ export async function bridge(
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413)
// Same discipline as the chunked-overrun path below: destroy, never
// drain. resume() would keep the socket open while the client trickles
// its declared length — an already-rejected request holding a server
// socket for as long as it likes.
res.writeHead(413, { connection: 'close' })
res.end()
req.resume()
req.destroy()
return
}
const chunks: Buffer[] = []
@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
import { bridge } from '../src/http-bridge.ts'
describe('HTTP bridge abort', () => {
it('destroys a declared-oversize request instead of draining it', async () => {
const destroyed: true[] = []
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, {
url: '/api/session.prompt',
method: 'POST',
headers: { 'content-type': 'application/json', 'content-length': '999999' },
destroy: () => { destroyed.push(true) },
})
let status: number | undefined
let headers: unknown
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
write() { return true },
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
}) as unknown as ServerResponse
await bridge(request, response, {
fetch: () => { throw new Error('a rejected request must never reach the handler') },
}, 1000)
// The socket must not stay parked draining a body the client can trickle
// at will after the rejection — same discipline as the chunked overrun.
expect(status).toBe(413)
expect(headers).toMatchObject({ connection: 'close' })
expect(destroyed).toHaveLength(1)
})
it('aborts a pending native picker request when the browser disconnects', async () => {
const body = JSON.stringify({
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
@@ -87,11 +87,17 @@ export class InputHub implements InputService {
for (const off of offs) off()
// Draft attachments die with the scope: the shell only holds ids, so
// the service-owned File objects and object URLs must be released
// here or they leak for the page lifetime.
// here or they leak for the page lifetime. The lookup is optional —
// during application teardown or HMR of this plugin the root
// `conversation` service can already be unregistered while session
// scopes are still alive; a throwing disposer would abort teardown
// quiescence, and the service's own disposal effect revokes every
// remaining URL in that case anyway.
const drafts = shell.snapshot.imageIds
shell.dispose()
this.shells.delete(id)
for (const imageId of drafts) this.conversation().releaseDraftImage(imageId)
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
}
}, 'conversation.input: session shell')
return shell
@@ -139,8 +145,18 @@ export class InputHub implements InputService {
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend(imageIds)
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
shell?.restoreImages(imageIds)
if (shell?.snapshot.draft === '') shell.setDraft(text)
// Restore only into the shell that still owns the session: if the scope
// died while the send was in flight, `commitSend` already removed the
// ids from the (now disposed) shell, so the teardown release could not
// see them — release the drafts here instead of resurrecting them onto
// a dead instance where they would leak for the page lifetime.
if (this.shells.get(session.sessionId) === shell) {
shell?.restoreImages(imageIds)
if (shell?.snapshot.draft === '') shell.setDraft(text)
return
}
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const id of imageIds) conversation?.releaseDraftImage(id)
})
}
@@ -69,6 +69,34 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('releases in-flight send images when the scope dies before the failure lands', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'b.png', { type: 'image/png' })])
if (attachment === undefined) throw new Error('draft attachment missing')
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
shell.addImages([attachment.id])
let reject!: (error: Error) => void
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
shell.setDraft('x')
shell.submit('queue')
// commitSend already removed the ids from the shell; kill the scope
// while the RPC is still pending, then land the failure.
await b.runtime.sessions.remove('s1')
reject(new Error('transport died'))
await vi.waitFor(() => {
expect(revoked).toHaveBeenCalledWith('blob:inflight-1')
})
expect(b.root.draftImages([attachment.id])).toEqual([])
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
+34 -25
View File
@@ -110,33 +110,48 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
}))
}
function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined {
/**
* The ONE recursive block walk shared by attachment authorization and the
* model-selection gate (nested tool-result content included). Both consumers
* must agree on what counts as replayed image content — a route added to one
* walker but not the other would silently skip authorization or stranding
* protection — so there is exactly one walker, parameterized by match.
*/
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
if (!Array.isArray(content)) return undefined
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (String(ref.attachmentId) === attachmentId) return ref
if (match(ref)) return ref
}
if (block.type === 'tool-result') {
const nested = imageInContent(block.content, attachmentId)
const nested = imageBlockIn(block.content, match)
if (nested !== undefined) return nested
}
}
return undefined
}
/** Every replayed content route of one event: direct content, wrapped message content, streamed block-end. */
function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } }
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
if (data.message !== undefined) {
const wrapped = imageBlockIn(data.message.content, match)
if (wrapped !== undefined) return wrapped
}
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
return imageBlockIn([data.chunk.block], match)
}
return undefined
}
/** True when any block (nested tool-result content included) is an image block. */
function contentHasImage(content: unknown): boolean {
if (!Array.isArray(content)) return false
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; content?: unknown }
if (block.type === 'image') return true
if (block.type === 'tool-result' && contentHasImage(block.content)) return true
}
return false
return imageBlockIn(content, () => true) !== undefined
}
/**
@@ -145,23 +160,13 @@ function contentHasImage(content: unknown): boolean {
* The log is immutable, so a true here is permanent for the session's life.
*/
function sessionHasImage(events: readonly SessionEvent[]): boolean {
return events.some((event) => {
const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } }
if (contentHasImage(data.content)) return true
if (data.message !== undefined && contentHasImage(data.message.content)) return true
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' && contentHasImage([data.chunk.block])
})
return events.some(event => imageInEvent(event, () => true) !== undefined)
}
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
const direct = imageInContent(data.content, attachmentId)
if (direct !== undefined) return direct
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
const streamed = imageInContent([data.chunk.block], attachmentId)
if (streamed !== undefined) return streamed
}
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
if (found !== undefined) return found
}
return undefined
}
@@ -1141,7 +1146,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// wire routes reject image content on text-only models — accepting
// this selection would strand the session (every turn fails, no
// in-product recovery). Refuse at the selection boundary instead.
if (sessionHasImage(found.agent.session.events)) {
// The pending inbox counts too: a queued image prompt enters the log
// only when claimed, which would happen AFTER this switch landed.
const queuedImage = (queuedMirror.get(sessionId) ?? [])
.some(entry => contentHasImage(entry.message.content))
if (queuedImage || sessionHasImage(found.agent.session.events)) {
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
return err(request, {
@@ -37,8 +37,6 @@ export const hostDescribeValueSchema = z.object({
mediaTypes: z.array(imageMediaTypeSchema),
}).optional(),
attachedSessions: z.number().int().nonnegative(),
// Open string, not a literal union: unknown kinds must survive the wire so
// a merge-added capability can advertise (the client hides the affordance).
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
@@ -274,6 +274,52 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => {
const { ctx, sessionId, agent } = await harness()
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
}('Text Only', []))
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
// The queued message enters the session log only when claimed — after a
// model switch would already have landed. The pending-inbox mirror must
// therefore gate the switch too.
ctx.emit('agent/inbox/enqueue', agent, {
id: 'q-1', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
} as never, 'queued')
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
expect(stranded.result.ok).toBe(false)
// Claiming the message drains the mirror; the log now owns the decision.
ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued')
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('authorizes an attachment read referenced only from wrapped message content', async () => {
const { ctx, sessionId, agent } = await harness()
const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 }
ctx.provide('attachments', {
readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }),
} as never)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
// The only reference lives inside an assistant/message wrapper — the same
// walk that gates model selection must authorize the read, or a real host
// denies galleries the fixture (with its own authorization mirror) serves.
agent.session.append('assistant/message', {
turn: 1, step: 0,
message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] },
} as never, { surfaceOp: 'append' })
const got = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-w' as never }))
expect(got.result).toMatchObject({ ok: true, value: { attachment: ref } })
const denied = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-other' as never }))
expect(denied.result).toMatchObject({ ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } } })
await ctx.fiber.dispose()
})
it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => {
const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
const cases: { label: string; append: (agent: Agent) => void }[] = [
+5 -1
View File
@@ -166,8 +166,12 @@ export class DeepSeekAdapter extends LlmAdapter {
const contextWindow = configured?.contextWindow
?? this.options.defaultContextWindow
return Promise.resolve({
// The chat-completions wire route is text-only regardless of catalog
// membership, so the uncatalogued fallback declares the same negative
// capability — "unknown" here would let the host accept and persist
// images the serializer must then reject.
...configured === undefined
? { provider, id: model, name: model }
? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] }
: modelInfo(provider, configured),
...contextWindow === undefined ? {} : { context: { contextWindow } },
...this.options.defaults?.thinking === 'disabled'
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12
README.md: ecd6da304a894a687153608f5b468f867ad32e6b
README.zh.md: 2812f2d74ec3dbab521f8e3148e9efe2640c6929
+1 -1
View File
@@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
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`.
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`.
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
+1 -1
View File
@@ -23,7 +23,7 @@
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER``INVALID_CATALOG` 失败。
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context``reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO``INVALID_MODEL_CONTEXT``INVALID_MODEL_REASONING` 失败。
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context``reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT``INVALID_MODEL_REASONING` 失败。
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
+33 -14
View File
@@ -13,6 +13,7 @@ import type {
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
ModelModality,
StreamChunk,
} from './types.ts'
import { freezeMessage, type Message } from './message.ts'
@@ -253,6 +254,28 @@ export class LlmService extends Service {
return this.registration(provider).retryPolicy
}
/**
* Validate adapter-owned modality arrays and detach them. One rule for the
* advisory catalog and exact resolution: both validate, both copy — two
* readings of the same adapter field with different trust or detachment
* would be an unexplained asymmetry.
* @param provider - provider route (diagnostic context).
* @param code - error code matching the calling surface.
* @param modalities - adapter-owned array, or undefined for unknown.
* @returns a detached copy, or undefined when absent.
*/
private detachedModalities(
provider: string,
code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO',
modalities: readonly unknown[] | undefined,
): ModelModality[] | undefined {
if (modalities === undefined) return undefined
if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) {
throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code)
}
return [...(modalities as readonly ModelModality[])]
}
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
@@ -277,13 +300,15 @@ export class LlmService extends Service {
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
}
seen.add(model.id)
const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities)
const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities)
return {
provider: model.provider,
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] },
...inputModalities === undefined ? {} : { inputModalities },
...outputModalities === undefined ? {} : { outputModalities },
}
})
}
@@ -333,23 +358,17 @@ export class LlmService extends Service {
'INVALID_MODEL_CONTEXT',
)
}
for (const modalities of [resolved.inputModalities, resolved.outputModalities]) {
if (modalities !== undefined && (!Array.isArray(modalities) || modalities.some(m => typeof m !== 'string'))) {
throw new LlmError(
`adapter returned invalid modality metadata for provider "${provider}" model "${model}"`,
'INVALID_MODEL_INFO',
)
}
}
// Capability metadata rides through: an explicit modality omission is
// negative capability downstream preflights act on (image admission).
const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities)
const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities)
const info: LlmResolvedModelInfo = {
provider,
id: model,
name: resolved.name,
...resolved.description === undefined ? {} : { description: resolved.description },
// Capability metadata rides through: an explicit modality omission is
// negative capability downstream preflights act on (image admission).
...resolved.inputModalities === undefined ? {} : { inputModalities: resolved.inputModalities },
...resolved.outputModalities === undefined ? {} : { outputModalities: resolved.outputModalities },
...inputModalities === undefined ? {} : { inputModalities },
...outputModalities === undefined ? {} : { outputModalities },
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
}
const reasoning = resolved.reasoning
+2
View File
@@ -1177,6 +1177,8 @@ describe('LlmService', () => {
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
[{ provider: 'route', id: 'm', name: '' }, 'empty name'],
[{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'],
[{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'],
[{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'],
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
const ctx = new Context()
await ctx.plugin(LlmService)