deepseek-harness/packages/llm/llm/src/content.ts

289 lines
12 KiB
TypeScript

/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
import type { ContentBlock } from './types.ts'
import type { Message } from './message.ts'
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import { assertNever } from '@deepseek-ai/dsh-util-values'
/** Execution-world path that model tools can use to read one normalized attachment. */
export interface ImageAttachmentAccess {
/** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
readonlyPath: string
}
/**
* Resolve current execution-world access for one durable image reference.
* @param ref - durable normalized attachment reference.
* @returns a read-only execution-world path, or undefined when unavailable.
*/
export type ImageAttachmentAccessResolver = (ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined
/**
* Bridge one attachment provider's host object location into the mounted
* tool execution world. The consumer supplies the current filesystem
* provider's mapping without making attachment or LLM definitions depend on it.
* @param attachments - provider that owns the normalized attachment object.
* @param mapHostPath - map one absolute host path into the current tool execution world.
* @param ref - durable normalized attachment reference.
* @returns a read-only execution-world path, or undefined when either provider exposes no mapping.
* @throws an attachment error when the durable reference is invalid.
*/
export function resolveImageAttachmentAccess(
attachments: AttachmentStore,
mapHostPath: (hostPath: string) => string | undefined,
ref: ImageAttachmentRef,
): ImageAttachmentAccess | undefined {
const hostPath = attachments.imageHostPath(ref)
if (hostPath === undefined) return undefined
const readonlyPath = mapHostPath(hostPath)
return readonlyPath === undefined ? undefined : { readonlyPath }
}
function quoted(value: string): string {
return JSON.stringify(value)
}
function imageIdentity(ref: ImageAttachmentRef): string {
return ref.name === undefined
? String(ref.attachmentId)
: `${quoted(ref.name)} (${ref.attachmentId})`
}
function extension(mediaType: ImageMediaType): string {
switch (mediaType) {
case 'image/png': return '.png'
case 'image/jpeg': return '.jpg'
case 'image/webp': return '.webp'
case 'image/gif': return '.gif'
default: return assertNever(mediaType, 'image extension')
}
}
function normalizedAccessText(ref: ImageAttachmentRef, access: ImageAttachmentAccess): string {
return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).`
+ ' Source dimensions, format, and byte size may differ.'
+ ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.`
}
/**
* Stable text shown to a model that cannot accept one durable image reference.
* @param ref - durable normalized attachment omitted from the request.
* @returns deterministic text-only placeholder.
*/
export function textOnlyImageText(ref: ImageAttachmentRef): string {
const digest = String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8)
return `[image omitted because this model accepts text only; attachment sha256:${digest}]`
}
/**
* Stable model-facing handle for one exact request image. Identity comes from
* the occurrence's own durable reference: request versions are prepared per
* attachment id, so one shared version may serve occurrences whose display
* names differ.
* @param ref - the occurrence's durable normalized attachment.
* @param version - exact request-image dimensions shown beside the text.
* @param access - optional path resolved for the current tool execution world.
* @returns attachment handle and request-image dimensions.
*/
export function requestImageHandleText(
ref: ImageAttachmentRef,
version: Pick<RequestImageAttachment, 'width' | 'height'>,
access?: ImageAttachmentAccess,
): string {
const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.`
return access === undefined
? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.`
: preview + normalizedAccessText(ref, access)
}
/**
* Stable per-image placeholder for a request-limit omission.
* @param ref - durable normalized attachment omitted from this request.
* @param access - optional provider-resolved path for model tools.
* @returns identity, normalized metadata, and the available recovery path.
*/
export function offloadedImageText(
ref: ImageAttachmentRef,
access?: ImageAttachmentAccess,
): string {
const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.`
if (access === undefined) {
return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]`
}
return `[${identity}${normalizedAccessText(ref, access)}]`
}
/**
* 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)))
}
/** Base64 length of raw image bytes, including padding. */
function base64Length(bytes: number): number {
return Math.ceil(bytes / 3) * 4
}
/** Byte accounting and quantized removal policy for one request representation. */
export interface RequestImageOffloadPolicy {
/** Image count accepted by the route; omission leaves count unbounded. */
maxImages?: number
/** Accumulated image bytes accepted by the route; omission leaves bytes unbounded. */
maxBytes?: number
/** Number of excess images removed as one deterministic step. */
countQuantum?: number
/** Number of excess bytes removed as one deterministic step. */
byteQuantum?: number
/** Whether byte accounting uses raw file bytes or inline base64 length. */
representation: 'raw' | 'base64'
/** Resolve the encoded request-version length; omission uses normalized attachment bytes. */
byteLength?: (ref: ImageAttachmentRef) => number
/** Build the model-visible replacement for each omitted attachment. */
placeholder: (ref: ImageAttachmentRef) => string
}
/** Collect represented image lengths in request and nested-block order. */
function collectImageLengths(
blocks: readonly ContentBlock[],
lengths: number[],
policy: RequestImageOffloadPolicy,
): void {
for (const block of blocks) {
if (block.type === 'image') {
const bytes = policy.byteLength === undefined
? block.attachment.bytes
: policy.byteLength(block.attachment)
lengths.push(policy.representation === 'base64' ? base64Length(bytes) : bytes)
} else if (block.type === 'tool-result') {
collectImageLengths(block.content, lengths, policy)
}
}
}
/** Replace the first `remaining.count` image occurrences without mutating durable messages. */
function replaceOldestImages(
blocks: readonly ContentBlock[],
remaining: { count: number },
placeholder: (ref: ImageAttachmentRef) => string,
): ContentBlock[] {
let next: ContentBlock[] | undefined
for (const [index, block] of blocks.entries()) {
if (block.type === 'image' && remaining.count > 0) {
remaining.count -= 1
next ??= blocks.slice(0, index)
next.push({ type: 'text', text: placeholder(block.attachment) })
continue
}
if (block.type === 'tool-result') {
const content = replaceOldestImages(block.content, remaining, placeholder)
if (content !== block.content) {
next ??= blocks.slice(0, index)
next.push({ ...block, content })
continue
}
}
next?.push(block)
}
return next ?? blocks as ContentBlock[]
}
/** Replace every image occurrence, including nested tool results, for a text-only model. */
function replaceImagesForTextModel(blocks: readonly ContentBlock[]): ContentBlock[] {
let next: ContentBlock[] | undefined
for (const [index, block] of blocks.entries()) {
if (block.type === 'image') {
next ??= blocks.slice(0, index)
next.push({ type: 'text', text: textOnlyImageText(block.attachment) })
continue
}
if (block.type === 'tool-result') {
const content = replaceImagesForTextModel(block.content)
if (content !== block.content) {
next ??= blocks.slice(0, index)
next.push({ ...block, content })
continue
}
}
next?.push(block)
}
return next ?? blocks as ContentBlock[]
}
/**
* Project durable image history into deterministic text for an exact text-only model.
* @param messages - complete request history.
* @returns the original list without images, otherwise shallow message copies with stable placeholders.
*/
export function projectImagesForTextModel(messages: readonly Message[]): readonly Message[] {
if (!messages.some(message => contentHasImage(message.content))) return messages
return messages.map((message) => {
const content = replaceImagesForTextModel(message.content)
return content === message.content ? message : { ...message, content }
})
}
/**
* Number of oldest image occurrences one request projection removes, in whole
* count and byte quanta, once a route budget is exceeded. The result depends
* only on the represented lengths, so provider request pricing reproduces the
* exact serialization decision without building the projected messages.
* @param lengths - represented byte length of every occurrence, in request order.
* @param policy - count/byte budgets and removal quanta; unbounded when absent.
* @returns how many leading occurrences the projection replaces with placeholders.
*/
export function offloadedImagePrefixCount(
lengths: readonly number[],
policy: Pick<RequestImageOffloadPolicy, 'maxImages' | 'maxBytes' | 'countQuantum' | 'byteQuantum'>,
): number {
const total = lengths.reduce((sum, bytes) => sum + bytes, 0)
const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages)
const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes)
if (excessCount === 0 && excessBytes === 0) return 0
const countQuantum = policy.countQuantum ?? 1
const byteQuantum = policy.byteQuantum ?? 1
const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum
const removeBytes = excessBytes === 0 ? 0 : Math.ceil(excessBytes / byteQuantum) * byteQuantum
let count = 0
let removedBytes = 0
for (const imageBytes of lengths) {
const byteTargetMet = removeBytes === 0
|| (byteQuantum === 1 ? removedBytes >= removeBytes : removedBytes > removeBytes)
if (count >= removeCount && byteTargetMet) break
removedBytes += imageBytes
count += 1
}
return count
}
/**
* Return a deterministic transient projection whose oldest images are replaced
* in whole count and byte quanta after a route budget is exceeded. The target
* depends only on complete durable history: at 129 one-megabyte images under
* a 128 MiB bound with a 64 MiB quantum, the oldest 65 images are removed so
* 64 MiB remain; that removed prefix stays fixed until total history exceeds
* 192 MiB.
* @param messages - complete request history, oldest first.
* @param policy - route representation, budgets, and removal quanta.
* @returns original messages below both bounds, otherwise shallow copies with deterministic placeholders.
*/
export function offloadRequestImagesWithPolicy(
messages: readonly Message[],
policy: RequestImageOffloadPolicy,
): readonly Message[] {
const lengths: number[] = []
for (const message of messages) collectImageLengths(message.content, lengths, policy)
const count = offloadedImagePrefixCount(lengths, policy)
if (count === 0) return messages
const remaining = { count }
return messages.map((message) => {
const content = replaceOldestImages(message.content, remaining, policy.placeholder)
return content === message.content ? message : { ...message, content }
})
}