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:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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.md: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1
|
||||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f13648051bcf4c81c8b032897645c9b6aeb0d32a
|
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 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.
|
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 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。
|
宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 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 仍是文本,绝不自动下载。
|
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||||
README.md: 98b09f7da745d152e90a4ad84ee1639b20ab295b
|
README.md: 337370f6b3738cef22bc826501633b9aca1f8c62
|
||||||
README.zh.md: c8d632c63868dd1906c9bce05c88134ea98104a2
|
README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711
|
||||||
|
|||||||
@@ -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;
|
- 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.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TUI 界面:
|
|||||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
|
- 应用 `~/.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>` 选择对应路由:出货清单内的 provider(openai/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`。
|
已交付的 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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** One profile-json key mapped onto a yml row's config field. */
|
||||||
interface ProfileMapping {
|
interface ProfileMapping {
|
||||||
jsonPath: string
|
jsonPath: string
|
||||||
@@ -170,7 +198,11 @@ export interface AppCLIEntryOptions {
|
|||||||
port?: number
|
port?: number
|
||||||
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
|
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
|
||||||
workspaceRoot?: string
|
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
|
provider?: string
|
||||||
/** Host default model override. */
|
/** Host default model override. */
|
||||||
model?: string
|
model?: string
|
||||||
@@ -262,14 +294,11 @@ export class AppCLIEntry {
|
|||||||
if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model)
|
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 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({
|
const route = resolveLlmRoute({
|
||||||
cli: { provider: this.options.provider, model: this.options.model },
|
cli: { provider: this.options.provider, model: this.options.model },
|
||||||
profile: { provider: profile.provider, model: profile.model },
|
profile: { provider: profile.provider, model: profile.model },
|
||||||
gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model },
|
gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model },
|
||||||
ymlPiAiProviders: (piAiRow?.providers ?? [])
|
ymlPiAiProviders: ymlPiAiProvidersOf(rows),
|
||||||
.map(p => p.provider)
|
|
||||||
.filter((value): value is string => typeof value === 'string'),
|
|
||||||
})
|
})
|
||||||
this.piAiProvider = route.dynamicPiAiProvider
|
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. */
|
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
|
||||||
private parseYmlRows(): Map<string, { config?: unknown }> {
|
private parseYmlRows(): Map<string, { config?: unknown }> {
|
||||||
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
|
return parseIncludeYmlRows(this.options.configPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Profile json under cwd; read-only — never created here, absent = no user config. */
|
/** Profile json under cwd; read-only — never created here, absent = no user config. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */
|
/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */
|
||||||
|
import { join } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
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. */
|
/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */
|
||||||
const SHIPPED = {
|
const SHIPPED = {
|
||||||
@@ -52,6 +53,20 @@ describe('resolveLlmRoute', () => {
|
|||||||
})).toThrow(/provider anthropic requires an explicit model/)
|
})).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', () => {
|
it('fails loud on a missing or empty provider', () => {
|
||||||
expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] }))
|
expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] }))
|
||||||
.toThrow(/provider must be a non-empty string/)
|
.toThrow(/provider must be a non-empty string/)
|
||||||
|
|||||||
@@ -120,24 +120,41 @@ it('renders the history image pair through the authorized attachment route and o
|
|||||||
await openFixtureSession()
|
await openFixtureSession()
|
||||||
|
|
||||||
// Both the user-side (align=end) and assistant-side (align=start) galleries
|
// 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(() => {
|
await waitFor(() => {
|
||||||
const user = document.querySelector('[data-align="end"] img')
|
if (document.querySelector('[data-align="end"] img') === null
|
||||||
const assistant = document.querySelector('[data-align="start"] img')
|
|| document.querySelector('[data-align="start"] img') === null) {
|
||||||
if (user === null || assistant === null) throw new Error('history image galleries missing')
|
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,)/)
|
|
||||||
}, { timeout: 10_000 })
|
}, { 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')!
|
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.
|
// Double-click opens the original-size lightbox; Escape/close dismisses it.
|
||||||
const frame = userImage.closest('button')
|
const frame = userImage.closest('button')
|
||||||
if (frame === null) throw new Error('image frame button missing')
|
if (frame === null) throw new Error('image frame button missing')
|
||||||
fireEvent.doubleClick(frame)
|
fireEvent.doubleClick(frame)
|
||||||
const lightbox = await screen.findByRole('dialog')
|
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: /关闭/ }))
|
fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('dialog')).toBeNull()
|
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')
|
if (el === null) throw new Error('attachment rail missing')
|
||||||
return el
|
return el
|
||||||
}, { timeout: 5_000 })
|
}, { 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^="移除图片"]')
|
const remove = rail.querySelector('button[aria-label^="移除图片"]')
|
||||||
if (remove === null) throw new Error('remove button missing')
|
if (remove === null) throw new Error('remove button missing')
|
||||||
|
|||||||
@@ -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)
|
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/*`
|
## `session/*`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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`
|
## `ctx.permission` — `PermissionService`
|
||||||
|
|
||||||
|
|||||||
@@ -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/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) |
|
| `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) |
|
| `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/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/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) |
|
| `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 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 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)
|
await chmod(target, 0o700)
|
||||||
if (firstCreated === undefined) return
|
let level = target
|
||||||
|
while (level !== stop) {
|
||||||
const highestCreated = resolve(firstCreated)
|
const parent = dirname(level)
|
||||||
let created = target
|
await syncDirectory(parent)
|
||||||
while (true) {
|
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
|
||||||
await syncDirectory(dirname(created))
|
if (parent === level) return
|
||||||
if (created === highestCreated) return
|
level = parent
|
||||||
created = dirname(created)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +117,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
|||||||
const sha256 = digest(input.data)
|
const sha256 = digest(input.data)
|
||||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||||
const staging = join(root, 'tmp')
|
const staging = join(root, 'tmp')
|
||||||
await ensureDurableDirectory(bucket)
|
// The durable boundary is the root's grandparent (DSH_HOME for the
|
||||||
await ensureDurableDirectory(staging)
|
// 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 temporary = join(staging, randomUUID())
|
||||||
const target = objectPath(root, sha256)
|
const target = objectPath(root, sha256)
|
||||||
let handle
|
let handle
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('local attachment store', () => {
|
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 storageRoot = await root()
|
||||||
const base = join(storageRoot, '..', '..')
|
const base = join(storageRoot, '..', '..')
|
||||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
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)
|
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([
|
expect(fsControl.syncedDirectories).toEqual([
|
||||||
|
// bucket chain: every parent entry between the bucket and the boundary.
|
||||||
objects,
|
objects,
|
||||||
storageRoot,
|
storageRoot,
|
||||||
join(storageRoot, '..'),
|
join(storageRoot, '..'),
|
||||||
base,
|
base,
|
||||||
|
// staging chain re-walks the shared ancestors after creating tmp.
|
||||||
storageRoot,
|
storageRoot,
|
||||||
|
join(storageRoot, '..'),
|
||||||
|
base,
|
||||||
|
// publication: the settled object's bucket and its parent for the rename.
|
||||||
bucket,
|
bucket,
|
||||||
objects,
|
objects,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ export async function bridge(
|
|||||||
})
|
})
|
||||||
const declaredLength = req.headers['content-length']
|
const declaredLength = req.headers['content-length']
|
||||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
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()
|
res.end()
|
||||||
req.resume()
|
req.destroy()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
|
|||||||
@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { bridge } from '../src/http-bridge.ts'
|
import { bridge } from '../src/http-bridge.ts'
|
||||||
|
|
||||||
describe('HTTP bridge abort', () => {
|
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 () => {
|
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
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()
|
for (const off of offs) off()
|
||||||
// Draft attachments die with the scope: the shell only holds ids, so
|
// Draft attachments die with the scope: the shell only holds ids, so
|
||||||
// the service-owned File objects and object URLs must be released
|
// 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
|
const drafts = shell.snapshot.imageIds
|
||||||
shell.dispose()
|
shell.dispose()
|
||||||
this.shells.delete(id)
|
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')
|
}, 'conversation.input: session shell')
|
||||||
return shell
|
return shell
|
||||||
@@ -139,8 +145,18 @@ export class InputHub implements InputService {
|
|||||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||||
shell?.commitSend(imageIds)
|
shell?.commitSend(imageIds)
|
||||||
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
|
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
|
||||||
shell?.restoreImages(imageIds)
|
// Restore only into the shell that still owns the session: if the scope
|
||||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
// 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()
|
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 () => {
|
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||||
|
|||||||
@@ -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
|
if (!Array.isArray(content)) return undefined
|
||||||
for (const value of content) {
|
for (const value of content) {
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||||
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
||||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||||
const ref = block.attachment as ImageAttachmentRef
|
const ref = block.attachment as ImageAttachmentRef
|
||||||
if (String(ref.attachmentId) === attachmentId) return ref
|
if (match(ref)) return ref
|
||||||
}
|
}
|
||||||
if (block.type === 'tool-result') {
|
if (block.type === 'tool-result') {
|
||||||
const nested = imageInContent(block.content, attachmentId)
|
const nested = imageBlockIn(block.content, match)
|
||||||
if (nested !== undefined) return nested
|
if (nested !== undefined) return nested
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
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. */
|
/** True when any block (nested tool-result content included) is an image block. */
|
||||||
function contentHasImage(content: unknown): boolean {
|
function contentHasImage(content: unknown): boolean {
|
||||||
if (!Array.isArray(content)) return false
|
return imageBlockIn(content, () => true) !== undefined
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,23 +160,13 @@ function contentHasImage(content: unknown): boolean {
|
|||||||
* The log is immutable, so a true here is permanent for the session's life.
|
* The log is immutable, so a true here is permanent for the session's life.
|
||||||
*/
|
*/
|
||||||
function sessionHasImage(events: readonly SessionEvent[]): boolean {
|
function sessionHasImage(events: readonly SessionEvent[]): boolean {
|
||||||
return events.some((event) => {
|
return events.some(event => imageInEvent(event, () => true) !== undefined)
|
||||||
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])
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
|
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
|
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
|
||||||
const direct = imageInContent(data.content, attachmentId)
|
if (found !== undefined) return found
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1146,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
// wire routes reject image content on text-only models — accepting
|
// wire routes reject image content on text-only models — accepting
|
||||||
// this selection would strand the session (every turn fails, no
|
// this selection would strand the session (every turn fails, no
|
||||||
// in-product recovery). Refuse at the selection boundary instead.
|
// 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)
|
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
|
||||||
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
||||||
return err(request, {
|
return err(request, {
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ export const hostDescribeValueSchema = z.object({
|
|||||||
mediaTypes: z.array(imageMediaTypeSchema),
|
mediaTypes: z.array(imageMediaTypeSchema),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
attachedSessions: z.number().int().nonnegative(),
|
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'>>>
|
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||||
|
|
||||||
/** host.pickDirectory request payload (empty object literal). */
|
/** host.pickDirectory request payload (empty object literal). */
|
||||||
|
|||||||
@@ -274,6 +274,52 @@ describe('Web session model selection', () => {
|
|||||||
await ctx.fiber.dispose()
|
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 () => {
|
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 image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
|
||||||
const cases: { label: string; append: (agent: Agent) => void }[] = [
|
const cases: { label: string; append: (agent: Agent) => void }[] = [
|
||||||
|
|||||||
@@ -166,8 +166,12 @@ export class DeepSeekAdapter extends LlmAdapter {
|
|||||||
const contextWindow = configured?.contextWindow
|
const contextWindow = configured?.contextWindow
|
||||||
?? this.options.defaultContextWindow
|
?? this.options.defaultContextWindow
|
||||||
return Promise.resolve({
|
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
|
...configured === undefined
|
||||||
? { provider, id: model, name: model }
|
? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] }
|
||||||
: modelInfo(provider, configured),
|
: modelInfo(provider, configured),
|
||||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||||
...this.options.defaults?.thinking === 'disabled'
|
...this.options.defaults?.thinking === 'disabled'
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||||
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
|
README.md: ecd6da304a894a687153608f5b468f867ad32e6b
|
||||||
README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12
|
README.zh.md: 2812f2d74ec3dbab521f8e3148e9efe2640c6929
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
提供方与模型元数据是发现表层,不是路由白名单。`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` 失败。
|
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
LlmModelInfo,
|
LlmModelInfo,
|
||||||
LlmResolvedModelInfo,
|
LlmResolvedModelInfo,
|
||||||
LlmProviderInfo,
|
LlmProviderInfo,
|
||||||
|
ModelModality,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
import { freezeMessage, type Message } from './message.ts'
|
import { freezeMessage, type Message } from './message.ts'
|
||||||
@@ -253,6 +254,28 @@ export class LlmService extends Service {
|
|||||||
return this.registration(provider).retryPolicy
|
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
|
* Discover models advertised by one registered provider. Catalog membership
|
||||||
* is advisory and never changes routing or request validation.
|
* 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')
|
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
|
||||||
}
|
}
|
||||||
seen.add(model.id)
|
seen.add(model.id)
|
||||||
|
const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities)
|
||||||
|
const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities)
|
||||||
return {
|
return {
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
id: model.id,
|
id: model.id,
|
||||||
name: model.name,
|
name: model.name,
|
||||||
...model.description === undefined ? {} : { description: model.description },
|
...model.description === undefined ? {} : { description: model.description },
|
||||||
...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
|
...inputModalities === undefined ? {} : { inputModalities },
|
||||||
...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] },
|
...outputModalities === undefined ? {} : { outputModalities },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -333,23 +358,17 @@ export class LlmService extends Service {
|
|||||||
'INVALID_MODEL_CONTEXT',
|
'INVALID_MODEL_CONTEXT',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for (const modalities of [resolved.inputModalities, resolved.outputModalities]) {
|
// Capability metadata rides through: an explicit modality omission is
|
||||||
if (modalities !== undefined && (!Array.isArray(modalities) || modalities.some(m => typeof m !== 'string'))) {
|
// negative capability downstream preflights act on (image admission).
|
||||||
throw new LlmError(
|
const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities)
|
||||||
`adapter returned invalid modality metadata for provider "${provider}" model "${model}"`,
|
const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities)
|
||||||
'INVALID_MODEL_INFO',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const info: LlmResolvedModelInfo = {
|
const info: LlmResolvedModelInfo = {
|
||||||
provider,
|
provider,
|
||||||
id: model,
|
id: model,
|
||||||
name: resolved.name,
|
name: resolved.name,
|
||||||
...resolved.description === undefined ? {} : { description: resolved.description },
|
...resolved.description === undefined ? {} : { description: resolved.description },
|
||||||
// Capability metadata rides through: an explicit modality omission is
|
...inputModalities === undefined ? {} : { inputModalities },
|
||||||
// negative capability downstream preflights act on (image admission).
|
...outputModalities === undefined ? {} : { outputModalities },
|
||||||
...resolved.inputModalities === undefined ? {} : { inputModalities: resolved.inputModalities },
|
|
||||||
...resolved.outputModalities === undefined ? {} : { outputModalities: resolved.outputModalities },
|
|
||||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||||
}
|
}
|
||||||
const reasoning = resolved.reasoning
|
const reasoning = resolved.reasoning
|
||||||
|
|||||||
@@ -1177,6 +1177,8 @@ describe('LlmService', () => {
|
|||||||
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
|
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
|
||||||
[{ provider: 'route', id: 'm', name: '' }, 'empty 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', 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) => {
|
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
|
|||||||
Reference in New Issue
Block a user