fix: harden Web image admission
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` 失败。
|
||||
|
||||
## 提供方/模型路由与回放
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user