fix: harden Web image admission

This commit is contained in:
Tianyi Cui
2026-07-30 01:58:36 +08:00
parent d6c82001b3
commit 515d48875e
52 changed files with 999 additions and 444 deletions

View File

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

View File

@@ -45,7 +45,7 @@ Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reason
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent.
Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. Image detection and conversion recurse through nested `tool-result` content, so a nested image is neither flattened nor skipped. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent.
## Provider/model routing and replay

View File

@@ -45,7 +45,7 @@
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent智能体级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`
图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。
图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。图片检测与转换会递归遍历嵌套的 `tool-result` 内容,因此嵌套图片既不会被展平,也不会被跳过。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。
## 提供方/模型路由与回放

View File

@@ -33,7 +33,7 @@ import type {
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { contentHasImage, toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}. */
@@ -192,8 +192,7 @@ export class PiAiAdapter extends LlmAdapter {
const containsImage = options.messages.some((message) => {
// The discriminant is part of same-process message validity and is read before content.
void message.role
return message.content.some(block => block.type === 'image'
|| (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))
return contentHasImage(message.content)
})
if (containsImage && !model.input.includes('image')) {
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')

View File

@@ -18,6 +18,23 @@ function flattenText(message: Message): string {
.join('')
}
/**
* Return whether content contains an image, including nested tool results.
* @param blocks - content to inspect recursively.
* @returns whether any nested block is an image.
*/
export function contentHasImage(blocks: readonly ContentBlock[]): boolean {
return blocks.some(block => block.type === 'image'
|| (block.type === 'tool-result' && contentHasImage(block.content)))
}
/** Flatten text recursively inside one tool result. */
function toolResultText(blocks: readonly ContentBlock[]): string {
return blocks.map(block => block.type === 'text'
? block.text
: block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
}
async function userContent(
blocks: readonly ContentBlock[],
attachments: AttachmentStore,
@@ -38,6 +55,14 @@ async function userContent(
break
}
case 'tool-result':
{
const nested = await userContent(block.content, attachments)
if (typeof nested === 'string') {
if (nested.length > 0) content.push({ type: 'text', text: nested })
} else {
content.push(...nested)
}
}
break
default:
// Other merge-extensible blocks are not user-input vocabulary for pi-ai.
@@ -72,8 +97,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
if (message.content.some(block => block.type === 'image'
|| (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) {
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
if (message.role === 'system') {
@@ -96,7 +120,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
content: [{
type: 'text',
text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)',
text: toolResultText(result.content) || '(no output)',
}],
isError: result.isError ?? false,
timestamp: 0,
@@ -131,7 +155,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta
for (const message of options.messages) {
if (message.role === 'system') {
if (message.content.some(block => block.type === 'image')) {
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT')
}
// pi-ai has a single systemPrompt slot; in-history system messages are

View File

@@ -256,8 +256,8 @@ describe('PiAiAdapter provider routing', () => {
mediaTypes: ['image/png'],
}
validateImage(_input: SaveImageAttachment): void {
throw new Error('not used')
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.reject(new Error('not used'))
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
@@ -613,8 +613,12 @@ describe('provider profile lifecycle', () => {
messages: [createUserMessage({
content: [{
type: 'tool-result',
toolCallId: 'call-image' as never,
content: [{ type: 'image', attachment: IMAGE_REF }],
toolCallId: 'call-outer' as never,
content: [{
type: 'tool-result',
toolCallId: 'call-inner' as never,
content: [{ type: 'image', attachment: IMAGE_REF }],
}],
}],
source: { kind: 'plugin', plugin: 'test' },
})],

View File

@@ -97,6 +97,55 @@ describe('toPiContext', () => {
})
})
it('flattens nested tool-result images into the enclosing result', async () => {
const attachment = {
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 3,
width: 1,
height: 1,
}
const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) })
const context = await toPiContext({
provider: 'openai',
model: 'gpt-4.1',
messages: [createUserMessage({
content: [{
type: 'tool-result',
toolCallId: CallId('outer'),
content: [
{ type: 'tool-result', toolCallId: CallId('empty'), content: [] },
{ type: 'text', text: 'before' },
{ type: 'tool-result', toolCallId: CallId('text'), content: [{ type: 'text', text: 'middle' }] },
{
type: 'tool-result',
toolCallId: CallId('inner'),
content: [
{ type: 'image', attachment },
{ type: 'text', text: 'after' },
],
},
],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
}, { readImage } as unknown as AttachmentStore)
expect(context.messages).toEqual([{
role: 'toolResult',
toolCallId: 'outer',
toolName: 'unknown',
content: [
{ type: 'text', text: 'before' },
{ type: 'text', text: 'middle' },
{ type: 'image', data: 'AQID', mimeType: 'image/png' },
{ type: 'text', text: 'after' },
],
isError: false,
timestamp: 0,
}])
})
it('rejects structured image history when no durable resolver is supplied', () => {
expect(() => toPiContext({
provider: 'openai', model: 'gpt-4.1',
@@ -205,7 +254,15 @@ describe('toPiContext', () => {
source: { kind: 'plugin', plugin: 'test' },
}),
createUserMessage({
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
content: [{
type: 'tool-result',
toolCallId: CallId('c1'),
content: [
{ type: 'text', text: 'Sunny' },
{ type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'text', text: '!' }] },
{ type: 'chart', data: 'ignored' } as unknown as ContentBlock,
],
}],
source: { kind: 'plugin', plugin: 'test' },
}),
],
@@ -214,7 +271,7 @@ describe('toPiContext', () => {
role: 'toolResult',
toolCallId: 'c1',
toolName: 'get_weather',
content: [{ type: 'text', text: 'Sunny' }],
content: [{ type: 'text', text: 'Sunny!' }],
isError: false,
timestamp: 0,
})

View File

@@ -74,8 +74,8 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
mediaTypes: [fixture.ref.mediaType],
}
validateImage(_input: SaveImageAttachment): void {
throw new Error('e2e attachment fixture is read-only')
validateImage(_input: SaveImageAttachment): Promise<void> {
return Promise.reject(new Error('e2e attachment fixture is read-only'))
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {