test(images): close unified pipeline coverage gaps

This commit is contained in:
creatixchu
2026-08-21 15:06:11 +08:00
parent 72b204afa1
commit d65e2a9e8a
17 changed files with 526 additions and 39 deletions
@@ -93,7 +93,11 @@ class SharedRequest<T> {
wait(signal?: AbortSignal): Promise<T> {
signal?.throwIfAborted()
this.waiters += 1
if (signal === undefined) return this.promise.finally(() => this.release(false))
if (signal === undefined) {
return this.promise.finally(() => {
this.release(false)
})
}
let released = false
const release = (cancelled: boolean): void => {
if (released) return
@@ -113,6 +117,8 @@ class SharedRequest<T> {
}, (error: unknown) => {
signal.removeEventListener('abort', abort)
release(false)
// CompressionLimiter normalizes task rejections before this handler.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
reject(error)
})
})
@@ -83,6 +83,7 @@ describe('CompressionLimiter', () => {
it('normalizes a non-Error rejection and releases its slot', async () => {
const limiter = new CompressionLimiter(1)
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Native bindings can reject non-Error values.
const failed = limiter.run(() => Promise.reject('native failure'))
const next = limiter.run(() => Promise.resolve('next'))
@@ -340,7 +340,9 @@ describe('local request-image cache', () => {
const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => {
readSignal = signal
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
signal?.addEventListener('abort', () => {
reject(new Error('request transform aborted', { cause: signal.reason }))
}, { once: true })
})
})
const controller = new AbortController()
@@ -349,7 +351,9 @@ describe('local request-image cache', () => {
{ maxPixels: 640_000, maxBytes: 1024 * 1024 },
controller.signal,
)
await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1))
await vi.waitFor(() => {
expect(read).toHaveBeenCalledTimes(1)
})
const reason = new Error('cancel only transform waiter')
controller.abort(reason)
@@ -369,7 +373,9 @@ describe('local request-image cache', () => {
calls += 1
if (calls === 1) {
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
signal?.addEventListener('abort', () => {
reject(new Error('request transform aborted', { cause: signal.reason }))
}, { once: true })
})
}
return actualRead(ref, signal)
@@ -377,7 +383,9 @@ describe('local request-image cache', () => {
const controller = new AbortController()
const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
const cancelled = attachments.readImageRequest(master, policy, controller.signal)
await vi.waitFor(() => expect(calls).toBe(1))
await vi.waitFor(() => {
expect(calls).toBe(1)
})
controller.abort('cancelled')
const replacement = attachments.readImageRequest(master, policy)
@@ -389,4 +397,5 @@ describe('local request-image cache', () => {
await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 })
expect(calls).toBe(2)
})
})
@@ -251,6 +251,33 @@ describe('read_image_region', () => {
expect(result.isError).toBe(false)
})
it('continues across an earlier session message without the requested image', async () => {
const ctx = await setup()
const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' })
const history = [
createUserMessage({
content: [{ type: 'text', text: 'before image' }],
source: { kind: 'plugin', plugin: 'test' },
}),
createUserMessage({
content: [{ type: 'image', attachment: source.ref }],
source: { kind: 'plugin', plugin: 'test' },
}),
]
const result = await call(ctx, 'read_image_region', {
attachment_id: source.ref.attachmentId,
preview_width: 3,
preview_height: 3,
x: 0,
y: 0,
width: 1,
height: 1,
}, agentOn('vision-model', 'visual', history))
expect(result.isError).toBe(false)
})
it('rejects a missing session, empty id, and invalid coordinate arguments', async () => {
const ctx = await setup()
const base = {
@@ -487,9 +487,10 @@ describe('image attachments', () => {
})
}),
validateImageBatch(inputs: readonly unknown[]) {
return (AttachmentStore.prototype as unknown as {
const validate = AttachmentStore.prototype as unknown as {
validateImageBatch(this: unknown, batch: readonly unknown[]): void
}).validateImageBatch.call(this, inputs)
}
validate.validateImageBatch.call(this, inputs)
},
// The real base-class batch method over this double's limits and members.
saveImages(inputs: readonly unknown[]) {
+24 -13
View File
@@ -50,33 +50,44 @@ function abortReason(signal: AbortSignal): Error {
: new Error('DeepSeek file upload cancelled with a non-Error reason.', { cause: reason })
}
function uploadFailure(error: unknown): Error {
return error instanceof Error
? error
: new Error('DeepSeek file upload failed with a non-Error reason.', { cause: error })
}
function waitForUpload(operation: SharedUpload, signal: AbortSignal | undefined): Promise<DeepSeekFileReference> {
signal?.throwIfAborted()
operation.waiters += 1
let released = false
const release = (cancelled: boolean): void => {
const release = (cancelledReason?: Error): void => {
if (released) return
released = true
operation.waiters -= 1
if (cancelled && operation.waiters === 0 && !operation.settled) {
operation.controller.abort(signal === undefined ? undefined : abortReason(signal))
if (cancelledReason !== undefined && operation.waiters === 0 && !operation.settled) {
operation.controller.abort(cancelledReason)
}
}
if (signal === undefined) return operation.promise.finally(() => release(false))
if (signal === undefined) {
return operation.promise.finally(() => {
release()
})
}
return new Promise<DeepSeekFileReference>((resolve, reject) => {
const abort = (): void => {
release(true)
reject(abortReason(signal))
const reason = abortReason(signal)
release(reason)
reject(reason)
}
signal.addEventListener('abort', abort, { once: true })
void operation.promise.then((value) => {
signal.removeEventListener('abort', abort)
release(false)
release()
resolve(value)
}, (error: unknown) => {
signal.removeEventListener('abort', abort)
release(false)
reject(error)
release()
reject(uploadFailure(error))
})
})
}
@@ -155,7 +166,7 @@ export class DeepSeekFileStore {
return value
}, (error: unknown) => {
shared.settled = true
throw error
throw uploadFailure(error)
})
this.inflight.set(key, shared)
void shared.promise.finally(() => {
@@ -168,7 +179,7 @@ export class DeepSeekFileStore {
version: RequestImageAttachment,
connection: DeepSeekFileConnection,
policy: DeepSeekFilePolicy,
signal?: AbortSignal,
signal: AbortSignal,
): Promise<DeepSeekFileReference> {
if (version.bytes > MAX_CHAT_IMAGE_BYTES) {
throw new LlmError('DeepSeek chat image exceeds the 32 MiB per-image limit.', 'INVALID_REQUEST')
@@ -186,9 +197,9 @@ export class DeepSeekFileStore {
mediaType: version.mediaType,
filename: filename(version),
expiresAfterSeconds: policy.expiresAfterSeconds,
...signal === undefined ? {} : { signal },
signal,
})
if (remote.bytes !== version.data.byteLength || remote.expiresAt === undefined) {
if (remote.bytes !== version.data.byteLength) {
throw new LlmError('DeepSeek Files API upload response does not match the submitted image.', 'INVALID_RESPONSE')
}
return {
+2 -3
View File
@@ -143,7 +143,6 @@ export class DeepSeekFilesClient {
let response: Response
try {
const headers = new Headers(attributionHeaders())
for (const [name, value] of new Headers(init.headers)) headers.set(name, value)
headers.set('authorization', `Bearer ${this.apiKey}`)
response = await this.fetchImpl(`${this.baseURL}${path}`, {
...init,
@@ -180,7 +179,7 @@ export class DeepSeekFilesClient {
filename: string
expiresAfterSeconds: number
signal?: AbortSignal
}): Promise<DeepSeekFileObject> {
}): Promise<DeepSeekFileObject & { expiresAt: number }> {
if (input.data.byteLength > MAX_FILE_UPLOAD_BYTES) {
throw new LlmError('DeepSeek Files API upload exceeds 128 MiB.', 'INVALID_REQUEST')
}
@@ -197,7 +196,7 @@ export class DeepSeekFilesClient {
const response = await this.request('/files', { method: 'POST', body: form }, input.signal)
const file = parseFileObject(await response.json(), 'upload')
if (file.expiresAt === undefined) throw invalidResponse('upload')
return file
return { ...file, expiresAt: file.expiresAt }
}
/**
+1 -1
View File
@@ -317,7 +317,7 @@ export async function serializeMessagesWithImages(
wire.push({
role: 'tool',
tool_call_id: result.toolCallId,
content: text || (fileParts.length > 0 ? '(see attached image)' : '(no output)'),
content: text || '(no output)',
})
pendingToolImages.push(...fileParts)
}
@@ -176,6 +176,7 @@ describe('DeepSeekAdapter against a mock server', () => {
await drain(adapter.stream({
provider: 'deepseek-official',
model: 'deepseek-v4-flash-vision-exp',
tools: [{ name: 'read_image_region', description: 'crop', parameters: { type: 'object' } }],
messages: [createUserMessage({
content: [
{ type: 'text', text: 'describe ' },
@@ -191,7 +192,7 @@ describe('DeepSeekAdapter against a mock server', () => {
role: 'user',
content: [
{ type: 'text', text: 'describe ' },
{ type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}`) as string },
{ type: 'text', text: expect.stringContaining('Call read_image_region') as string },
{ type: 'file', file_id: 'file-api-1' },
],
}],
@@ -1499,6 +1500,23 @@ describe('plugin registration and config', () => {
.toThrow(/maxTokens must be a positive integer/)
})
it('rejects image request limits on a text-only catalog model', () => {
expect(() => resolveAdapterOptions({
models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }],
})).toThrow(/text-only catalog model .* cannot declare image request limits/)
})
it.each([
['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/],
['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/],
['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/],
['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/],
] as const)('rejects per-model %s=%s', (field, value, message) => {
expect(() => resolveAdapterOptions({
models: [{ id: 'vision', inputModalities: ['image'], [field]: value }],
})).toThrow(message)
})
it('prefers a model\'s own output cap over the profile default', async () => {
// The profile default stays what an unlisted or uncapped model resolves
// to, so adding a per-model cap changes one model rather than the route.
@@ -1569,6 +1587,23 @@ describe('plugin registration and config', () => {
})).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/)
})
it.each([
['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/],
['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/],
['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/],
['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/],
['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/],
['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/],
['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/],
['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/],
['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/],
['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/],
['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/],
['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/],
] as const)('rejects %s=%s', (field, value, message) => {
expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message)
})
it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid request file bound %s',
async (maxRequestFilesBytes) => {
@@ -4,8 +4,9 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import { DeepSeekFileStore } from '../src/file-store.ts'
import { DeepSeekUploadIndex } from '../src/upload-index.ts'
import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts'
import { DeepSeekFileId } from '../src/file-id.ts'
import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
const REF: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
@@ -90,7 +91,9 @@ describe('DeepSeekFileStore', () => {
uploadSignal = init?.signal ?? undefined
return new Promise<Response>((resolve, reject) => {
complete = resolve
uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true })
uploadSignal?.addEventListener('abort', () => {
reject(new Error('upload aborted', { cause: uploadSignal?.reason }))
}, { once: true })
})
}) as typeof fetch
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl })
@@ -98,7 +101,9 @@ describe('DeepSeekFileStore', () => {
const cancelled = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal)
const completed = store.ensureUploaded(VERSION, CONNECTION, POLICY)
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1))
await vi.waitFor(() => {
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
const reason = new Error('cancel one upload waiter')
controller.abort(reason)
@@ -123,13 +128,17 @@ describe('DeepSeekFileStore', () => {
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
uploadSignal = init?.signal ?? undefined
return new Promise<Response>((_resolve, reject) => {
uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { once: true })
uploadSignal?.addEventListener('abort', () => {
reject(new Error('upload aborted', { cause: uploadSignal?.reason }))
}, { once: true })
})
}) as typeof fetch
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl })
const controller = new AbortController()
const upload = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal)
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1))
await vi.waitFor(() => {
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
const reason = new Error('cancel only upload waiter')
controller.abort(reason)
@@ -138,6 +147,79 @@ describe('DeepSeekFileStore', () => {
expect(uploadSignal?.reason).toBe(reason)
})
it('normalizes a non-Error cancellation reason', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => (
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new Error('upload aborted', { cause: init.signal?.reason }))
}, { once: true })
})
)) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
now: () => NOW,
fetch: fetchImpl,
})
const controller = new AbortController()
const upload = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal)
await vi.waitFor(() => {
expect(fetchImpl).toHaveBeenCalledOnce()
})
controller.abort('cancelled')
await expect(upload).rejects.toMatchObject({
message: 'DeepSeek file upload cancelled with a non-Error reason.',
cause: 'cancelled',
})
})
it('starts a fresh upload while the cancelled transport is settling', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
let requests = 0
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
requests += 1
if (requests === 1) {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
queueMicrotask(() => {
reject(new Error('upload aborted', { cause: init.signal?.reason }))
})
}, { once: true })
})
}
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-retry', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-retry.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
}), { status: 200 }))
}) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
now: () => NOW,
fetch: fetchImpl,
})
const controller = new AbortController()
const cancelled = store.ensureUploaded(VERSION, CONNECTION, POLICY, controller.signal)
await vi.waitFor(() => {
expect(fetchImpl).toHaveBeenCalledOnce()
})
controller.abort(new Error('cancel first'))
const retried = store.ensureUploaded(VERSION, CONNECTION, POLICY)
await expect(cancelled).rejects.toThrow('cancel first')
await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } })
})
it('rejects a request version above the chat per-image limit before transport', async () => {
const fetchImpl = vi.fn() as typeof fetch
const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl })
const oversized = { ...VERSION, bytes: MAX_CHAT_IMAGE_BYTES + 1 }
await expect(store.ensureUploaded(oversized, CONNECTION, POLICY))
.rejects.toMatchObject({ code: 'INVALID_REQUEST' })
expect(fetchImpl).not.toHaveBeenCalled()
})
it('does not persist an upload whose response is missing and retries on the next request', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
@@ -158,6 +240,55 @@ describe('DeepSeekFileStore', () => {
.resolves.toMatchObject({ record: { fileId: 'file-api-1' }, uploaded: true })
})
it('rejects an upload response whose byte count differs from the request version', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000,
filename: 'dsh-wrong.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
}), { status: 200 }))) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
now: () => NOW,
fetch: fetchImpl,
})
await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY))
.rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
it.each([
['image/jpeg', 'jpeg'],
['image/webp', 'webp'],
['image/gif', 'gif'],
] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const remote = uploadFetch()
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)),
now: () => NOW,
fetch: remote.fetchImpl,
})
await store.ensureUploaded({ ...VERSION, mediaType }, CONNECTION, POLICY)
const form = vi.mocked(remote.fetchImpl).mock.calls[0]?.[1]?.body
expect(form).toBeInstanceOf(FormData)
const file = (form as FormData).get('file')
expect(file).toBeInstanceOf(File)
if (!(file instanceof File)) throw new Error('expected multipart file')
expect(file.name).toMatch(new RegExp(`\\.${extension}$`, 'u'))
})
it('normalizes a non-Error failure from the durable upload index', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
vi.spyOn(index, 'get').mockRejectedValue('index unavailable')
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).rejects.toMatchObject({
message: 'DeepSeek file upload failed with a non-Error reason.',
cause: 'index unavailable',
})
})
it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
@@ -190,6 +321,101 @@ describe('DeepSeekFileStore', () => {
expect(remote.fetchImpl).toHaveBeenCalledTimes(2)
})
it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
vi.spyOn(index, 'commit').mockResolvedValue({
accepted: false,
record: {
scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey),
masterAttachmentId: VERSION.master.attachmentId,
variantId: VERSION.variantId,
fileId: DeepSeekFileId('file-api-winner'),
bytes: 3,
createdAt: NOW,
expiresAt: NOW + POLICY.expiresAfterSeconds * 1_000,
},
})
const remote = uploadFetch()
const fetchImpl = vi.fn((url: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'DELETE') return Promise.resolve(new Response('failed', { status: 500 }))
return remote.fetchImpl(url, init)
}) as typeof fetch
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: fetchImpl })
await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).resolves.toMatchObject({
record: { fileId: 'file-api-winner' },
uploaded: false,
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('reclaims one owned file after quota rejection and retries the upload once', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
let uploads = 0
const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'POST') {
uploads += 1
if (uploads === 1) return Promise.resolve(new Response(JSON.stringify({
error: { message: 'stored file quota exceeded', code: 'file_quota' },
}), { status: 400 }))
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-recovered', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-recovered.png', purpose: 'user_data',
expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds,
}), { status: 200 }))
}
if (init?.method === 'DELETE') {
return Promise.resolve(new Response(JSON.stringify({
id: 'file-api-old', object: 'file', deleted: true,
}), { status: 200 }))
}
expect(new URL(requestUrl(input)).pathname).toBe('/files')
return Promise.resolve(new Response(JSON.stringify({
object: 'list',
data: [{
id: 'file-api-old', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'dsh-old.png', purpose: 'user_data',
}],
first_id: 'file-api-old', last_id: 'file-api-old', has_more: false,
}), { status: 200 }))
}) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
now: () => NOW,
fetch: fetchImpl,
})
await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).resolves.toMatchObject({
record: { fileId: 'file-api-recovered' }, uploaded: true,
})
expect(uploads).toBe(2)
})
it('preserves a quota error when no harness-owned file can be reclaimed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({
error: { message: 'file count quota exceeded', code: 'file_quota' },
}), { status: 400 }))
return Promise.resolve(new Response(JSON.stringify({
object: 'list',
data: [{
id: 'file-api-foreign', object: 'file', bytes: 3, created_at: NOW / 1_000,
filename: 'foreign.png', purpose: 'user_data',
}],
has_more: false,
}), { status: 200 }))
}) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, 'index.json')),
now: () => NOW,
fetch: fetchImpl,
})
await expect(store.ensureUploaded(VERSION, CONNECTION, POLICY)).rejects.toMatchObject({ code: 'FILES_API' })
})
it('finishes pagination before deleting cursor files during quota recovery', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const deleted = new Set<string>()
@@ -227,4 +453,44 @@ describe('DeepSeekFileStore', () => {
await expect(store.reclaimOldestOwned(CONNECTION, 2)).resolves.toBe(2)
expect([...deleted]).toEqual(['file-api-oldest', 'file-api-next'])
})
it('stops pagination when a page omits or repeats its cursor', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
for (const mode of ['missing', 'repeated'] as const) {
let page = 0
const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'DELETE') {
const id = requestUrl(input).split('/').at(-1)
return Promise.resolve(new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }))
}
page += 1
const lastId = mode === 'missing' ? undefined : 'file-api-same'
return Promise.resolve(new Response(JSON.stringify({
object: 'list', data: [], has_more: true,
...lastId === undefined ? {} : { last_id: lastId },
}), { status: 200 }))
}) as typeof fetch
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, `${mode}.json`)),
now: () => NOW,
fetch: fetchImpl,
})
await expect(store.reclaimOldestOwned(CONNECTION, 1)).resolves.toBe(0)
expect(page).toBe(mode === 'missing' ? 1 : 2)
}
})
it('releases every batch and clears the scoped upload index', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
const reclaim = vi.spyOn(store, 'reclaimOldestOwned')
.mockResolvedValueOnce(1_000)
.mockResolvedValueOnce(2)
const clear = vi.spyOn(index, 'clear')
await expect(store.releaseAll(CONNECTION)).resolves.toBe(1_002)
expect(reclaim).toHaveBeenCalledTimes(2)
expect(clear).toHaveBeenCalledOnce()
})
})
@@ -119,7 +119,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))) as typeof fetch,
fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))),
})
await expect(client.retrieve(DeepSeekFileId('missing'))).rejects.toMatchObject({
name: 'DeepSeekFilesError',
@@ -139,7 +139,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))) as typeof fetch,
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))),
})
const error = await client.retrieve(DeepSeekFileId('missing')).catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(DeepSeekFilesError)
@@ -151,7 +151,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.reject(transport)) as typeof fetch,
fetch: vi.fn(() => Promise.reject(transport)),
})
await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({
code: 'TRANSPORT',
@@ -183,7 +183,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch,
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
})
await expect(client.retrieve(DeepSeekFileId('one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
@@ -224,7 +224,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch,
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
})
await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
@@ -253,7 +253,7 @@ describe('DeepSeekFilesClient', () => {
const client = new DeepSeekFilesClient({
baseURL: 'https://api.deepseek.com',
apiKey: 'key',
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))) as typeof fetch,
fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))),
})
await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
})
@@ -399,6 +399,14 @@ describe('image serialization', () => {
})
})
it('rejects an image whose prepared request version is absent', async () => {
const ref = imageRef()
await expect(serializeMessagesWithImages([createUserMessage({
content: [{ type: 'image', attachment: ref }],
source: { kind: 'plugin', plugin: 'test' },
})], imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
})
it('keeps tool content textual and groups consecutive tool-result images afterward', async () => {
const messages = [
createUserMessage({
@@ -561,6 +569,17 @@ describe('image serialization', () => {
expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ master: { mediaType: 'image/jpeg' } })
})
it('rejects an unprepared image while computing exact request bytes', async () => {
const ref = imageRef()
await expect(serializeRequestWithImages(request({
model: 'deepseek-v4-flash-vision-exp',
messages: [createUserMessage({
content: [{ type: 'image', attachment: ref }],
source: { kind: 'plugin', plugin: 'test' },
})],
}), imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
})
it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => {
const resolveFileId = vi.fn()
await expect(serializeMessagesWithImages([createMessage({
@@ -64,3 +64,26 @@ describe('modality schema boundary', () => {
expect(absent.providers['acme-gateway']?.defaultInput).toEqual(['text'])
})
})
describe('request image policy bounds', () => {
it.each([
['requestImagePixelBudget', 0, /requestImagePixelBudget must be a positive safe integer/],
['requestImagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /requestImagePixelBudget must be a positive safe integer/],
['requestImageMaxBytes', 0, /requestImageMaxBytes must be a positive safe integer/],
['requestImageMaxBytes', 1.5, /requestImageMaxBytes must be a positive safe integer/],
] as const)('rejects %s=%s at service resolution', (field, value, message) => {
const programmatic = {
providers: {
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'm' }],
[field]: value,
},
},
} as unknown as Config
expect(() => {
assertServiceable(programmatic)
}).toThrow(message)
})
})
@@ -428,4 +428,14 @@ describe('pi-ai request context conversion', () => {
history('assistant', [{ type: 'image', attachment: ref }]),
)).toThrow(/assistant image output/)
})
it('rejects an attachment service that omits a requested image version', async () => {
const store = {
readImageRequests: vi.fn(() => Promise.resolve([])),
} as unknown as AttachmentStore
await expect(toPiContext(
request([user([{ type: 'image', attachment: ref }])]),
store,
)).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
})
})
+3 -1
View File
@@ -74,7 +74,9 @@ function collectImageLengths(
): void {
for (const block of blocks) {
if (block.type === 'image') {
const bytes = policy.byteLength?.(block.attachment) ?? block.attachment.bytes
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)
+67 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages, offloadRequestImagesWithPolicy } from '../src/index.ts'
import {
CallId,
createUserMessage,
OFFLOADED_IMAGE_TEXT,
offloadRequestImages,
offloadRequestImagesWithPolicy,
projectImagesForTextModel,
} from '../src/index.ts'
import type { ContentBlock } from '../src/index.ts'
const source = { kind: 'plugin' as const, plugin: 'test' }
@@ -19,6 +26,11 @@ function image(bytes: number): ContentBlock {
}
describe('offloadRequestImages', () => {
it('preserves every image when no payload bound is configured', () => {
const messages = [createUserMessage({ content: [image(300)], source })]
expect(offloadRequestImages(messages, undefined)).toBe(messages)
})
it('preserves the original request when its base64 payload fits exactly', () => {
const messages = [createUserMessage({ content: [image(3), image(3)], source })]
expect(offloadRequestImages(messages, 8)).toBe(messages)
@@ -116,4 +128,58 @@ describe('offloadRequestImagesWithPolicy', () => {
expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20)
expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581)
})
it('uses route-owned request byte lengths when supplied', () => {
const messages = [createUserMessage({ content: [image(100), image(100)], source })]
const projected = offloadRequestImagesWithPolicy(messages, {
representation: 'raw',
maxBytes: 3,
byteLength: () => 2,
})
expect(projected[0]?.content).toEqual([
{ type: 'text', text: OFFLOADED_IMAGE_TEXT },
image(100),
])
})
})
describe('projectImagesForTextModel', () => {
it('returns image-free history unchanged', () => {
const messages = [createUserMessage({ content: [{ type: 'text', text: 'plain' }], source })]
expect(projectImagesForTextModel(messages)).toBe(messages)
})
it('replaces direct and nested images while retaining unaffected messages and blocks', () => {
const plain = createUserMessage({ content: [{ type: 'text', text: 'plain' }], source })
const nested = {
type: 'tool-result' as const,
toolCallId: CallId('nested-image'),
content: [{ type: 'text' as const, text: 'before' }, image(3), { type: 'text' as const, text: 'after' }],
}
const unchangedNested = {
type: 'tool-result' as const,
toolCallId: CallId('text-only'),
content: [{ type: 'text' as const, text: 'unchanged' }],
}
const visual = createUserMessage({
content: [{ type: 'text', text: 'lead' }, image(3), unchangedNested, nested],
source,
})
const projected = projectImagesForTextModel([plain, visual])
expect(projected[0]).toBe(plain)
expect(projected[1]?.content).toEqual([
{ type: 'text', text: 'lead' },
{ type: 'text', text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]' },
unchangedNested,
{
...nested,
content: [
{ type: 'text', text: 'before' },
{ type: 'text', text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]' },
{ type: 'text', text: 'after' },
],
},
])
})
})
+12
View File
@@ -986,6 +986,18 @@ describe('LlmRuntime', () => {
type: 'text',
text: '[image omitted because this model accepts text only; attachment sha256:aaaaaaaa]',
}])
const frozen = Object.freeze({
provider: 'route',
model: 'text-only',
messages: [createUserMessage({
content: [{ type: 'image', attachment }],
source: { kind: 'plugin' as const, plugin: 'test' },
})],
})
await collect(ctx.llm.stream(frozen))
expect(Object.isFrozen(seen[1])).toBe(true)
expect(Object.isFrozen(seen[1]?.messages)).toBe(true)
})
it('passes cancellation through exact-model resolution', async () => {