fix: address ds-review-bot v7 findings on the merged image-input head
- gate model selection on steering-placement image carriers from enqueue until their steering/message event publishes; release the gate when an admission ends idle without publication (both behaviorally asserted) - reject session.updateQueue edits carrying non-text blocks at the RPC boundary (queue edits cannot bypass image admission) - extend the durable-directory walk past a first-created DSH_HOME to the deepest pre-existing ancestor - strip Windows-style separators from attachment display names on POSIX - verify attachment reads with a header-only probe (digest already proves the bytes decoded fully at admission); document the read path - make SessionInputShell.addImages refusal observable and keep workspace transfers/composer intake from leaking refused drafts - own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy, pi-ai, compact-basic, and the DeepSeek text-only assertion - drop the redundant canonical-base64 regex and the no-op role read - move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts); document why AttachmentError does not extend HarnessError - document the hard attachments inject in both consumer READMEs
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { WireMessage, WireRequest, WireTool } from './types.ts'
|
||||
|
||||
@@ -62,11 +62,8 @@ function flattenText(blocks: ContentBlock[]): string {
|
||||
|
||||
/** Reject core image content before any text-flattening path can silently erase it. */
|
||||
function assertTextOnly(blocks: readonly ContentBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'image') {
|
||||
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
if (block.type === 'tool-result') assertTextOnly(block.content)
|
||||
if (contentHasImage(blocks)) {
|
||||
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ import type {
|
||||
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveProfiles } from './config.ts'
|
||||
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { contentHasImage, toPiContext } from './context.ts'
|
||||
import { contentHasImage } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext } from './context.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}. */
|
||||
@@ -189,11 +190,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
|
||||
|
||||
try {
|
||||
const containsImage = options.messages.some((message) => {
|
||||
// The discriminant is part of same-process message validity and is read before content.
|
||||
void message.role
|
||||
return contentHasImage(message.content)
|
||||
})
|
||||
const containsImage = options.messages.some(message => contentHasImage(message.content))
|
||||
if (containsImage && !model.input.includes('image')) {
|
||||
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module dsh-llm-pi-ai/context
|
||||
*/
|
||||
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
|
||||
@@ -18,15 +18,6 @@ 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 {
|
||||
|
||||
@@ -638,7 +638,7 @@ describe('provider profile lifecycle', () => {
|
||||
describe('abort wiring', () => {
|
||||
it('preserves an unknown pre-dispatch adapter Error exactly', async () => {
|
||||
const original = new Error('SDK context conversion exploded')
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
const message = Object.defineProperty({}, 'content', {
|
||||
get() { throw original },
|
||||
})
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
|
||||
@@ -656,7 +656,7 @@ describe('abort wiring', () => {
|
||||
it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => {
|
||||
const controller = new AbortController()
|
||||
const original = new Error('conversion lost its caller')
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
const message = Object.defineProperty({}, 'content', {
|
||||
get() {
|
||||
controller.abort('caller cancelled during conversion')
|
||||
throw original
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
|
||||
|
||||
import type { ContentBlock } from './types.ts'
|
||||
|
||||
/**
|
||||
* True when typed model content contains an image block, walking nested
|
||||
* tool-result content. This is the one recursive image walk shared by every
|
||||
* image policy (capability gating, text-only serialization, compaction
|
||||
* survey), so a consumer cannot silently diverge on nesting depth.
|
||||
* @param content - typed model content blocks.
|
||||
* @returns whether any nested block is an image.
|
||||
*/
|
||||
export function contentHasImage(content: readonly ContentBlock[]): boolean {
|
||||
return content.some(block => block.type === 'image'
|
||||
|| (block.type === 'tool-result' && contentHasImage(block.content)))
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './types.ts'
|
||||
export * from './content.ts'
|
||||
export * from './message.ts'
|
||||
export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
|
||||
Reference in New Issue
Block a user