Merge pull request #5214 from deepseek-harness/worktree/deepseek-stale-file-batch-invalidate

fix(llm): remove every stale DeepSeek file mapping in one index update
This commit is contained in:
CreatixChu authored and GitHub committed 2026-09-25 16:56:54 +08:00
commit 783569bf73
10 files changed
+100 -29

No files matched your search

+2 -2
View File
@@ -39,8 +39,8 @@
en: f863390198cc177c
zh: 8856c62ba3fb36ca
/deepseek-ai-dsh-llm-deepseek/use-this-package/failures-and-recovery:
en: d31bd24c8c419f88
zh: 91205cb3cd754f39
en: 7a09ab81593cd9c1
zh: 919dc0eb6347e9fd
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation:
en: 1598ac8a8bb9b6bf
zh: fdd089c5ae7722cd
+1 -1
View File
@@ -115,7 +115,7 @@ Configuration accepts Messages only and has no `protocol` field. If resolution r
Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity.
Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_<status>` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_<status>` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection removes every named mapping (or every mapping used by the attempt) in one upload-index update and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
Provider plugins own catalog availability; only the account route requires a stored grant for discovery. Their catalogs are configured independently; the transport supplies shared default model metadata and capability resolution.
+1 -1
View File
@@ -115,7 +115,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。
非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_<status>`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_<status>`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会在一次上传索引更新中删除全部点名映射(或该次尝试使用的全部映射),并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
提供方插件负责目录可用性;仅账号路由要求存有凭据才能发现模型。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。
+5 -11
View File
@@ -244,21 +244,15 @@ export class DeepSeekFileStore {
}
/**
* Invalidate one exact local mapping after a model request rejects its remote id.
* @param version - request-image version whose remote generation failed.
* @param fileId - exact rejected file id.
* Invalidate exact local mappings in one index update after a model request rejects their remote ids.
* @param generations - request-image variants with the exact file id the request used for each.
* @param connection - endpoint and API-key snapshot.
*/
async invalidate(
version: RequestImageAttachment,
fileId: DeepSeekFileId,
generations: readonly Pick<DeepSeekUploadRecord, 'variantId' | 'fileId'>[],
connection: DeepSeekFileConnection,
): Promise<void> {
await this.index.remove(
fileScope(connection),
version.variantId,
fileId,
)
await this.index.remove(fileScope(connection), generations)
}
/**
@@ -284,7 +278,7 @@ export class DeepSeekFileStore {
)
if (record === undefined) return false
await this.client(connection).delete(record.fileId, signal)
await this.index.remove(scope, version.variantId, record.fileId)
await this.index.remove(scope, [{ variantId: version.variantId, fileId: record.fileId }])
return true
}
@@ -128,13 +128,16 @@ export class RequestFiles {
}
/**
* Invalidate rejected mappings; only the first stale-id response permits another request.
* Invalidate every rejected mapping in one index update; only the first stale-id response permits another request.
* @param detail - provider error fields used for stale-id classification.
* @returns whether the caller should serialize and dispatch again.
*/
async retry(detail: string): Promise<boolean> {
if (this.used.length === 0 || !providerRejectedFileId(detail)) return false
await Promise.all(staleMappings(this.used, detail).map(file => this.files.invalidate(file.version, file.fileId, this.connection)))
await this.files.invalidate(
staleMappings(this.used, detail).map(file => ({ variantId: file.version.variantId, fileId: file.fileId })),
this.connection,
)
if (this.retried) return false
this.retried = true
return true
@@ -190,21 +190,20 @@ export class DeepSeekUploadIndex {
}
/**
* Remove one exact mapping without deleting a concurrently installed successor.
* Remove exact mappings in one locked rewrite without deleting concurrently installed successors.
* @param scope - endpoint/API-key namespace.
* @param variantId - complete request-image transformation identity.
* @param fileId - exact remote generation being invalidated.
* @param generations - exact remote generations being invalidated; pairs absent from the index are ignored.
*/
async remove(
scope: DeepSeekFileScopeType,
variantId: ImageVariantIdType,
fileId: DeepSeekFileIdType,
generations: readonly Pick<DeepSeekUploadRecord, 'variantId' | 'fileId'>[],
): Promise<void> {
const invalidated = new Set(generations.map(generation => `${generation.variantId}\0${generation.fileId}`))
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 })
await withFileLock(this.path, async () => {
const index = await this.load()
const records = index.records.filter(record => !(
record.scope === scope && record.variantId === variantId && record.fileId === fileId
record.scope === scope && invalidated.has(`${record.variantId}\0${record.fileId}`)
))
if (records.length !== index.records.length) await this.save({ formatVersion: 3, records })
})
@@ -98,10 +98,10 @@ describe('DeepSeekFileStore', () => {
expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record)
const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now })
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
await reopened.invalidate(VERSION, other.record.fileId, CONNECTION)
await reopened.invalidate([{ variantId: VERSION.variantId, fileId: other.record.fileId }], CONNECTION)
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
expect(remote.uploads()).toBe(2)
await reopened.invalidate(VERSION, first.record.fileId, CONNECTION)
await reopened.invalidate([{ variantId: VERSION.variantId, fileId: first.record.fileId }], CONNECTION)
const replacement = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)
expect(replacement.record.fileId).not.toBe(first.record.fileId)
expect((await reopened.ensureUploaded(VERSION, alternate, POLICY)).record).toEqual(other.record)
@@ -73,7 +73,7 @@ describe('Messages Files requests', () => {
vi.stubGlobal('fetch', fetchImpl)
const h = harness()
await chunks(h.adapter.stream(request([ref, second])))
expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({}))
expect(h.invalidate).toHaveBeenCalledExactlyOnceWith([{ variantId: version(ref).variantId, fileId: 'file-a' }], expect.objectContaining({}))
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(h.prepare).toHaveBeenCalledTimes(2)
expect(h.ensureUploaded).toHaveBeenCalledTimes(4)
@@ -85,7 +85,7 @@ describe('Messages Files requests', () => {
const h = harness()
await expect(chunks(h.adapter.stream(request([ref, second])))).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(h.invalidate).toHaveBeenCalledTimes(4)
expect(h.invalidate.mock.calls.map(call => call[0].length)).toEqual([2, 2])
expect(fetchImpl.mock.calls.every(call => !body(call[1]).includes('base64'))).toBe(true)
})
@@ -1017,6 +1017,57 @@ describe('DeepSeekAdapter against a mock server', () => {
])
})
it('recovers in one replacement request when the provider lists dozens of missing file ids', async () => {
const refs = Array.from({ length: 40 }, (_, index): ImageAttachmentRef => ({
...imageRef,
attachmentId: AttachmentId(`sha256:${(index + 1).toString(16).padStart(64, '0')}`),
}))
const missing = Array.from({ length: 30 }, (_, index) => `file-api-${index + 1}`)
const server = await mockServer([
{
kind: 'http-error',
status: 400,
body: JSON.stringify({
error: {
type: 'invalid_request_error',
message: 'messages.0.content.1.tool_result: the following file_ids do not exist or are not created under your account: '
+ `${missing.join(', ')} (request_id: 00000000-0000-4000-8000-000000000002)`,
},
}),
},
{ kind: 'sse', events: textEvents },
])
const attachments = attachmentStoreOf(ref => Promise.resolve({
...requestImage(ref),
variantId: ImageVariantId(String(ref.attachmentId)),
})).store
const adapter = adapterOf({
baseURL: server.url,
models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
}, attachments)
await drain(adapter.stream({
provider: 'deepseek-official',
model: 'deepseek-v4-flash-vision-exp',
messages: [createUserMessage({
content: refs.map(ref => ({ type: 'image' as const, attachment: ref })),
source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})],
}))
expect(server.requests).toHaveLength(2)
expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(70)
const attempts = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; source?: { file_id?: string } }> }> }>
const fileIds = (attempt: number) => attempts[attempt]?.messages[0]?.content
.filter(block => block.type === 'image')
.map(block => block.source?.file_id)
expect(fileIds(0)).toEqual(refs.map((_, index) => `file-api-${index + 1}`))
expect(fileIds(1)).toEqual([
...missing.map((_, index) => `file-api-${41 + index}`),
...refs.slice(30).map((_, index) => `file-api-${31 + index}`),
])
})
it('invalidates every used mapping when a stale-file response does not identify one file id', async () => {
const secondRef: ImageAttachmentRef = {
...imageRef,
@@ -56,12 +56,36 @@ describe('DeepSeekUploadIndex', () => {
await index.commit(first, 1, 1)
await expect(index.commit(duplicate, 2, 1)).resolves.toEqual({ record: first, accepted: false })
await index.remove(scope, VARIANT, duplicate.fileId)
await index.remove(scope, [{ variantId: VARIANT, fileId: duplicate.fileId }])
await expect(index.get(scope, VARIANT, 2, 1)).resolves.toEqual(first)
await index.remove(scope, VARIANT, first.fileId)
await index.remove(scope, [{ variantId: VARIANT, fileId: first.fileId }])
await expect(index.get(scope, VARIANT, 2, 1)).resolves.toBeUndefined()
})
it('removes several exact generations in one update and keeps unlisted mappings', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
const record = (digest: string) => ({
scope, attachmentId: ATTACHMENT, variantId: ImageVariantId(`sha256:${digest.repeat(64)}`),
fileId: DeepSeekFileId(`file-api-${digest}`), bytes: 3, createdAt: 1, expiresAt: 10_000,
})
const first = record('b')
const second = record('c')
const third = record('d')
for (const entry of [first, second, third]) await index.commit(entry, 1, 1)
await index.remove(scope, [
{ variantId: first.variantId, fileId: first.fileId },
{ variantId: second.variantId, fileId: second.fileId },
{ variantId: third.variantId, fileId: DeepSeekFileId('file-api-superseded') },
])
await expect(index.get(scope, first.variantId, 2, 1)).resolves.toBeUndefined()
await expect(index.get(scope, second.variantId, 2, 1)).resolves.toBeUndefined()
await expect(index.get(scope, third.variantId, 2, 1)).resolves.toEqual(third)
})
it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)