* test(api): measure historical image read costs * fix(test): resolve image benchmark paths from its config * fix(attachments): read stored images without scanning sessions * test(attachments): complete by-id fixtures and access assertions * fix(docs): implement image-id read on catalog fixture * docs(attachments): refresh generated read contracts * test(web): verify stored images across history and forks * docs(api): clarify image read metadata and permission scope * test(api): type benchmark instrumentation without unsafe returns * test(api): retire unknown-cast allowances with removed history fixtures * test(attachments): isolate browser cases and document store-wide access * test(web): intercept the by-id thumbnail read path * docs(perf): run generated diagnostic from its owning package * refactor(attachments): reuse reference reads for conversation images * docs(api): refresh complete image reference client contract * test(ui-chat): assert complete image reference forwarding * test(perf): measure image reads against current Session observations
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
|
|
|
|
import { Buffer } from 'node:buffer'
|
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts'
|
|
import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts'
|
|
import type {
|
|
AdmittedPromptContentPart,
|
|
AttachmentAdmissionPart,
|
|
EncodedFileAttachment,
|
|
FileAttachmentRef,
|
|
ImageAttachmentLimits,
|
|
ImageAttachmentRef,
|
|
ImageRequestTarget,
|
|
PromptContentPart,
|
|
RequestImageAttachment,
|
|
SaveFileAttachment,
|
|
SaveFileStreamAttachment,
|
|
SaveImageAttachment,
|
|
StoredImageAttachment,
|
|
} from './types.ts'
|
|
|
|
export { AttachmentId, ImageVariantId } from './brand.ts'
|
|
export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
|
|
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
|
export { admitEncodedFile, admitEncodedImages } from './admission.ts'
|
|
export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts'
|
|
export type { ProjectedDimensions } from './request-projection.ts'
|
|
export type {
|
|
AttachmentId as AttachmentIdType,
|
|
AdmittedPromptContentPart,
|
|
AttachmentAdmissionPart,
|
|
EncodedFileAttachment,
|
|
EncodedImageAttachment,
|
|
FileAttachmentRef,
|
|
ImageAttachmentLimits,
|
|
ImageAttachmentRef,
|
|
ImageRequestTarget,
|
|
ImageMediaType,
|
|
PromptContentPart,
|
|
RequestImageAttachment,
|
|
SaveFileAttachment,
|
|
SaveFileStreamAttachment,
|
|
SaveImageAttachment,
|
|
StoredImageAttachment,
|
|
} from './types.ts'
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
attachments: AttachmentStore
|
|
}
|
|
}
|
|
|
|
/** Whether one admission part still carries encoded image bytes. */
|
|
function isEncodedImage(part: AttachmentAdmissionPart): part is Extract<PromptContentPart, { readonly type: 'image' }> {
|
|
return part.type === 'image' && !('attachment' in part)
|
|
}
|
|
|
|
/** Whether one admission part references an image an earlier upload stored. */
|
|
function isStoredImage(
|
|
part: AttachmentAdmissionPart,
|
|
): part is Extract<AttachmentAdmissionPart, { readonly type: 'image'; readonly encodedBytes: number }> {
|
|
return part.type === 'image' && 'attachment' in part
|
|
}
|
|
|
|
/** Refuse one message whose images exceed the configured count or aggregate byte limit. */
|
|
function assertMessageImages(limits: ImageAttachmentLimits, count: number, bytes: number): void {
|
|
if (count > limits.maxImagesPerMessage) {
|
|
throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
|
}
|
|
if (bytes > limits.maxMessageImageBytes) {
|
|
throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
|
}
|
|
}
|
|
|
|
/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */
|
|
export abstract class AttachmentStore extends Service {
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'attachments')
|
|
}
|
|
|
|
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
|
|
abstract readonly imageLimits: ImageAttachmentLimits
|
|
|
|
/**
|
|
* Validate one image without persisting it.
|
|
* Batch callers validate every member before saving any member.
|
|
* @param input - encoded bytes, declared media type, and optional display name.
|
|
* @returns completion after the encoded raster has been fully decoded.
|
|
*/
|
|
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
|
|
|
/**
|
|
* Validate one ordered image batch before committing any member.
|
|
* Validation failures start no writes; storage failures return no partial
|
|
* references, although already published content-addressed objects may stay
|
|
* unreachable until a future retention policy collects them.
|
|
* @param inputs - encoded images in their owning message order.
|
|
* @returns durable references in the exact input order.
|
|
*/
|
|
protected validateImageBatch(inputs: readonly SaveImageAttachment[]): void {
|
|
const limits = this.imageLimits
|
|
assertMessageImages(limits, inputs.length, inputs.reduce((sum, input) => sum + input.data.byteLength, 0))
|
|
for (const input of inputs) {
|
|
if (!limits.mediaTypes.includes(input.mediaType)) {
|
|
throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and durably commit one ordered image batch.
|
|
* @param inputs - encoded images in owning-message order.
|
|
* @returns durable normalized attachment references in the same order after every member succeeds.
|
|
*/
|
|
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]> {
|
|
this.validateImageBatch(inputs)
|
|
for (const input of inputs) await this.validateImage(input)
|
|
|
|
const refs: ImageAttachmentRef[] = []
|
|
for (const input of inputs) refs.push(await this.saveImage(input))
|
|
return refs
|
|
}
|
|
|
|
/**
|
|
* Admit one Host prompt and replace each encoded image with its durable reference.
|
|
* Text, durable file references, and already stored images pass through unchanged.
|
|
* Every image part counts toward `maxImagesPerMessage` and `maxMessageImageBytes`;
|
|
* a stored image counts the bytes of the upload that stored it. Refused content
|
|
* starts no writes. A prompt without encoded images performs no storage operation.
|
|
* @param content - prompt parts in message order after upload receipt resolution.
|
|
* @returns admitted prompt parts in the same order as `content`.
|
|
* @throws AttachmentError when the image batch is refused.
|
|
*/
|
|
async admitPromptContent(
|
|
content: readonly AttachmentAdmissionPart[],
|
|
): Promise<AdmittedPromptContentPart[]> {
|
|
const encoded = content.filter(isEncodedImage)
|
|
const stored = content.filter(isStoredImage)
|
|
// Each upload passed the limits alone; the encoded batch below checks only itself.
|
|
if (stored.length > 0) {
|
|
assertMessageImages(
|
|
this.imageLimits,
|
|
encoded.length + stored.length,
|
|
encoded.reduce((sum, part) => sum + Buffer.byteLength(part.data, 'base64'), 0)
|
|
+ stored.reduce((sum, part) => sum + part.encodedBytes, 0),
|
|
)
|
|
}
|
|
const refs = encoded.length === 0 ? [] : await admitEncodedImages(this, encoded)
|
|
let next = 0
|
|
return content.map((part) => {
|
|
if (part.type === 'text') return { type: 'text', text: part.text }
|
|
if (part.type === 'file') return { type: 'file', attachment: part.attachment }
|
|
if ('attachment' in part) return { type: 'image', attachment: part.attachment }
|
|
return { type: 'image', attachment: refs[next++] as ImageAttachmentRef }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Decode and durably commit one canonical base64 file upload.
|
|
* @param input - canonical base64 bytes and optional display name.
|
|
* @returns the durable content-addressed file reference.
|
|
* @throws AttachmentError when the encoding or storage operation is refused.
|
|
*/
|
|
admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef> {
|
|
return admitFileInput(this, input)
|
|
}
|
|
|
|
/**
|
|
* Identify a failure emitted by this attachment capability by its stable code.
|
|
* @param error - value caught from an attachment operation.
|
|
* @returns whether the value is an attachment failure.
|
|
*/
|
|
isAttachmentError(error: unknown): error is AttachmentError {
|
|
return matchesAttachmentError(error)
|
|
}
|
|
|
|
/**
|
|
* Validate and durably commit one image before its owning session event is appended.
|
|
* The returned reference describes the persisted normalized image. When
|
|
* normalization reduces the raster, its `originalDimensions` records the
|
|
* orientation-applied input dimensions.
|
|
* @param input - encoded bytes, declared media type, and optional display name.
|
|
* @returns the durable content-addressed normalized image reference.
|
|
*/
|
|
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
|
|
|
/**
|
|
* Read one image and verify its ID, format, byte length, and dimensions against the supplied reference.
|
|
* @param ref - supplied image reference; name and originalDimensions are preserved without file verification.
|
|
* @param signal - optional cancellation for backend read and verification work.
|
|
* @returns the verified bytes and normalized attachment reference.
|
|
* @throws the signal reason when aborted, or a storage error when verification fails.
|
|
*/
|
|
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
|
|
|
/**
|
|
* Locate the provider-owned normalized object in the harness host filesystem.
|
|
* @param ref - durable normalized attachment reference.
|
|
* @returns an absolute host path, or undefined when this backend is not host-file-backed.
|
|
* @throws an AttachmentError when the durable reference is invalid.
|
|
*/
|
|
imageHostPath(ref: ImageAttachmentRef): string | undefined {
|
|
void ref
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Durably commit one file byte-for-byte before its owning session event is
|
|
* appended. Files carry no admission limits: any byte content and length is
|
|
* accepted, and the stored object is the exact submitted bytes. Backends
|
|
* without verbatim file storage keep this default rejection.
|
|
* @param input - exact bytes and optional display name.
|
|
* @returns the durable content-addressed file reference.
|
|
*/
|
|
saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef> {
|
|
void input
|
|
return Promise.reject(new AttachmentError(
|
|
'The mounted attachment provider cannot store verbatim files.',
|
|
'ATTACHMENT_FILES_UNSUPPORTED',
|
|
))
|
|
}
|
|
|
|
/**
|
|
* Durably commit one file byte-for-byte from bounded chunks. Providers must
|
|
* apply backpressure and must not collect the complete file in memory.
|
|
* Backends without streamed verbatim storage keep this default rejection.
|
|
* @param input - ordered exact bytes, optional cancellation, and display name.
|
|
* @returns the durable content-addressed file reference.
|
|
*/
|
|
saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef> {
|
|
void input
|
|
return Promise.reject(new AttachmentError(
|
|
'The mounted attachment provider cannot stream verbatim files.',
|
|
'ATTACHMENT_FILES_UNSUPPORTED',
|
|
))
|
|
}
|
|
|
|
/**
|
|
* Read and verify one verbatim stored file as bounded chunks. Providers must
|
|
* not collect the complete file in memory. Backends without verbatim file
|
|
* reads keep this default rejection.
|
|
* @param ref - durable file reference from the session log.
|
|
* @param signal - optional cancellation for backend reads and verification work.
|
|
* @returns exact file bytes in order; integrity failures reject the iteration.
|
|
*/
|
|
async *readFileStream(
|
|
ref: FileAttachmentRef,
|
|
signal?: AbortSignal,
|
|
): AsyncIterable<Uint8Array> {
|
|
signal?.throwIfAborted()
|
|
void ref
|
|
await Promise.reject(new AttachmentError(
|
|
'The mounted attachment provider cannot read verbatim files.',
|
|
'ATTACHMENT_FILES_UNSUPPORTED',
|
|
))
|
|
}
|
|
|
|
/**
|
|
* Locate the verbatim stored file object in the harness host filesystem.
|
|
* @param ref - durable file reference.
|
|
* @returns an absolute host path, or undefined when this backend is not host-file-backed.
|
|
* @throws an AttachmentError when the durable reference is invalid.
|
|
*/
|
|
fileHostPath(ref: FileAttachmentRef): string | undefined {
|
|
void ref
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Generate or read one deterministic model-request version from the stored normalized image.
|
|
* @param ref - durable provider-independent normalized attachment reference.
|
|
* @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
|
|
* @param signal - optional cancellation.
|
|
* @returns request bytes and the cache/upload identity covering every transform input.
|
|
*/
|
|
readImageRequest(
|
|
ref: ImageAttachmentRef,
|
|
target: ImageRequestTarget,
|
|
signal?: AbortSignal,
|
|
): Promise<RequestImageAttachment> {
|
|
signal?.throwIfAborted()
|
|
void ref
|
|
void target
|
|
return Promise.reject(new AttachmentError(
|
|
'The mounted attachment provider cannot derive model-request images.',
|
|
'ATTACHMENT_PROJECTION_UNSUPPORTED',
|
|
))
|
|
}
|
|
|
|
}
|
|
|
|
export default AttachmentStore
|