mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(llm): log repeated request-extension warnings once
Extension failures, unreadable package identities, a blocked session-log upload, and degraded Messages replay recur on every request. Log the first occurrence per field, route, module, or blocking event instead. Co-authored-by: turtle1999 <301751225+turtle1999@users.noreply.github.com>
This commit is contained in:
1 parent
0aed8de976
commit
7facb1cf65
27 files changed
+92
-51
No files matched your search
@@ -12,8 +12,8 @@
|
||||
en: 740e92e903ecb594
|
||||
zh: f934d3c9e5c56bfe
|
||||
/official-deepseek-llm-api-wire-extensions/body-extension-transaction:
|
||||
en: 9a0086300a5775cc
|
||||
zh: 7f2422d81d9bb0cc
|
||||
en: 48001cfd1281a23b
|
||||
zh: d410530ae8aa2823
|
||||
/official-deepseek-llm-api-wire-extensions/dsh-plugin-packages:
|
||||
en: f89059050613f3e8
|
||||
zh: da5cf2a4eeb81b75
|
||||
|
||||
@@ -34,7 +34,7 @@ Credential failure happens before anonymous-user-id resolution, so an unauthoriz
|
||||
|
||||
The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request.
|
||||
|
||||
Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. A contributor whose preparation fails is logged and omitted from that request while the other fields are still sent; a field that collides with a base field prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
|
||||
Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. A contributor whose preparation fails is omitted from that request, with only its first failure logged, while the other fields are still sent; a field that collides with a base field prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
|
||||
|
||||
After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure is logged and does not fail the model request; the contributor resends its unaccepted state on a later request. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。
|
||||
|
||||
系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统记录告警并在该次请求中省略其字段,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
|
||||
系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统在该次请求中省略其字段,且只记录首次失败,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
|
||||
|
||||
已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。接受失败会记录告警,但不会使模型请求失败;贡献方会在之后的请求中重发未被接受的状态。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。
|
||||
|
||||
|
||||
@@ -931,8 +931,8 @@ register<K extends keyof DeepSeekLlmApiExtensionMap>( field: K, provider: DeepSe
|
||||
|
||||
/**
|
||||
* Prepare every currently registered field from one immutable base request.
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is logged
|
||||
* and omitted from this request; only cancellation rejects. Field values are cloned
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is omitted
|
||||
* from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned
|
||||
* and frozen; providers retain no mutable alias to the outgoing request.
|
||||
* @param request - exact serialized request facts before extension fields.
|
||||
* @returns detached fields and their idempotent joint acceptance transaction.
|
||||
|
||||
@@ -937,8 +937,8 @@ register<K extends keyof DeepSeekLlmApiExtensionMap>( field: K, provider: DeepSe
|
||||
|
||||
/**
|
||||
* Prepare every currently registered field from one immutable base request.
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is logged
|
||||
* and omitted from this request; only cancellation rejects. Field values are cloned
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is omitted
|
||||
* from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned
|
||||
* and frozen; providers retain no mutable alias to the outgoing request.
|
||||
* @param request - exact serialized request facts before extension fields.
|
||||
* @returns detached fields and their idempotent joint acceptance transaction.
|
||||
|
||||
@@ -991,7 +991,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async prepare(request: DeepSeekLlmApiExtensionRequest): Promise<PreparedDeepSeekLlmApiExtensions>',
|
||||
description: 'Prepare every currently registered field from one immutable base request. A provider whose preparation throws, or whose value cannot be cloned, is logged and omitted from this request; only cancellation rejects. Field values are cloned and frozen; providers retain no mutable alias to the outgoing request.',
|
||||
description: 'Prepare every currently registered field from one immutable base request. A provider whose preparation throws, or whose value cannot be cloned, is omitted from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned and frozen; providers retain no mutable alias to the outgoing request.',
|
||||
parameters: [{ name: 'request', description: 'exact serialized request facts before extension fields.' }],
|
||||
returns: 'detached fields and their idempotent joint acceptance transaction.',
|
||||
},
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 6b7f62c318c9aad6
|
||||
zh: 5e22d58884e60c87
|
||||
/deepseek-ai-dsh-deepseek-llm-api-extensions/service:
|
||||
en: 82ca2d022af4f348
|
||||
zh: 95ecbd4a2d4ea1c2
|
||||
en: d9782207ffab7c50
|
||||
zh: 22c2a10a5f0de61f
|
||||
/deepseek-ai-dsh-deepseek-llm-api-extensions/model-experience:
|
||||
en: 88a785350ced5c14
|
||||
zh: 755349be023a3f5c
|
||||
|
||||
@@ -24,7 +24,7 @@ Provider-specific registry for additive top-level fields on official DeepSeek LL
|
||||
## Service
|
||||
|
||||
- `register(field, provider)` reserves one field for the calling fiber. Duplicate or malformed names fail synchronously; disposing the registration releases it for a later provider.
|
||||
- `prepare(request)` snapshots the registered providers, prepares them concurrently, clones and freezes returned JSON values, and returns `{ fields, accept }`. A provider whose preparation throws is logged and omitted while the other fields are returned; request cancellation rejects and stops awaiting providers even when one ignores its signal.
|
||||
- `prepare(request)` snapshots the registered providers, prepares them concurrently, clones and freezes returned JSON values, and returns `{ fields, accept }`. A provider whose preparation throws is omitted while the other fields are returned, and only its first failure is logged; request cancellation rejects and stops awaiting providers even when one ignores its signal.
|
||||
- `accept()` runs every captured post-2xx callback once. Concurrent calls join the same settlement, every callback settles before failures are reported, and several failures become one `AggregateError`.
|
||||
|
||||
Each provider sees the exact serialized Messages body, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance.
|
||||
|
||||
@@ -24,7 +24,7 @@ kind: "package-reference"
|
||||
## 服务
|
||||
|
||||
- `register(field, provider)` 为调用 fiber 保留一个字段。重复或格式错误的名称会同步失败;dispose(资源释放)该注册后,后续提供方可以再次认领。
|
||||
- `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。某个提供方准备时抛错,注册表会记录告警并省略其字段,其余字段照常返回;请求取消会使 `prepare` 拒绝,即使某个提供方忽略信号,注册表也会停止等待。
|
||||
- `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。某个提供方准备时抛错,注册表会省略其字段,其余字段照常返回,且只记录该字段的首次失败;请求取消会使 `prepare` 拒绝,即使某个提供方忽略信号,注册表也会停止等待。
|
||||
- `accept()` 对每个捕获的 2xx 后回调只运行一次。并发调用会等待同一次结算,所有回调都在报告失败前完成,多个失败会合并为一个 `AggregateError`。
|
||||
|
||||
每个提供方都会看到确切的已序列化 Messages 请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。
|
||||
|
||||
@@ -65,6 +65,8 @@ async function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
/** Registry of independently owned top-level fields for official DeepSeek requests. */
|
||||
export class DeepSeekLlmApiExtensionRegistry extends Service {
|
||||
private readonly providers = new Map<string, ErasedProvider>()
|
||||
/** Fields whose preparation failure was already logged; later failures stay silent. */
|
||||
private readonly warnedFields = new Set<string>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'deepseekLlmApiExtensions')
|
||||
@@ -100,8 +102,8 @@ export class DeepSeekLlmApiExtensionRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Prepare every currently registered field from one immutable base request.
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is logged
|
||||
* and omitted from this request; only cancellation rejects. Field values are cloned
|
||||
* A provider whose preparation throws, or whose value cannot be cloned, is omitted
|
||||
* from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned
|
||||
* and frozen; providers retain no mutable alias to the outgoing request.
|
||||
* @param request - exact serialized request facts before extension fields.
|
||||
* @returns detached fields and their idempotent joint acceptance transaction.
|
||||
@@ -114,7 +116,8 @@ export class DeepSeekLlmApiExtensionRegistry extends Service {
|
||||
const result = await provider.prepare(request)
|
||||
return result === undefined ? undefined : { field, value: freezeJson(structuredClone(result.value)), result }
|
||||
} catch (error) {
|
||||
if (!request.signal.aborted) {
|
||||
if (!request.signal.aborted && !this.warnedFields.has(field)) {
|
||||
this.warnedFields.add(field)
|
||||
this.ctx.logger.warn(`deepseek-llm-api-extensions: omitting field ${JSON.stringify(field)} from this request because its preparation failed: %o`, error)
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('DeepSeekLlmApiExtensionRegistry', () => {
|
||||
await expect(prepared.accept()).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('rejects invalid field names and omits a field whose preparation fails', async () => {
|
||||
it('rejects invalid field names and omits a failing field, warning once', async () => {
|
||||
const ctx = await harness()
|
||||
const warnings: unknown[][] = []
|
||||
ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
@@ -132,10 +132,12 @@ describe('DeepSeekLlmApiExtensionRegistry', () => {
|
||||
const accept = vi.fn()
|
||||
ctx.deepseekLlmApiExtensions.register('test_alpha', { prepare: () => { throw failure } })
|
||||
ctx.deepseekLlmApiExtensions.register('test_beta', { prepare: () => ({ value: [1], accept }) })
|
||||
const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL })
|
||||
expect(prepared.fields).toEqual({ test_beta: [1] })
|
||||
await prepared.accept()
|
||||
expect(accept).toHaveBeenCalledOnce()
|
||||
for (let request = 0; request < 2; request++) {
|
||||
const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL })
|
||||
expect(prepared.fields).toEqual({ test_beta: [1] })
|
||||
await prepared.accept()
|
||||
}
|
||||
expect(accept).toHaveBeenCalledTimes(2)
|
||||
expect(warnings).toEqual([[
|
||||
'deepseek-llm-api-extensions: omitting field "test_alpha" from this request because its preparation failed: %o',
|
||||
failure,
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 49290fc5647136b4
|
||||
zh: 96fa9ce0f9e83c13
|
||||
/deepseek-ai-dsh-llm-deepseek/use-this-package/provider-specific-request-fields:
|
||||
en: f863390198cc177c
|
||||
zh: 8856c62ba3fb36ca
|
||||
en: f13f24b504dcfb50
|
||||
zh: 69b0ed9e2adf3394
|
||||
/deepseek-ai-dsh-llm-deepseek/use-this-package/failures-and-recovery:
|
||||
en: 58de81c17b72e67a
|
||||
zh: a5add6133016721b
|
||||
|
||||
@@ -107,7 +107,7 @@ Connection options are captured from volatile Config references once per operati
|
||||
|
||||
### Provider-specific request fields
|
||||
|
||||
When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. When the base request with its extension fields fails to serialize, the adapter sends the base request alone, skips acceptance so contributors resend their state later, and logs a warning naming the omitted fields. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
|
||||
When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. The registry omits a field whose preparation fails, and field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. When the base request with its extension fields fails to serialize, the adapter sends the base request alone, skips acceptance so contributors resend their state later, and logs a warning naming the omitted fields. Serialization, acceptance, and replay-degradation warnings are logged once per route. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input.
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
|
||||
|
||||
### 提供方专用请求字段
|
||||
|
||||
存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。基础请求连同扩展字段无法序列化时,适配器只发送基础请求并跳过接受,让贡献方在之后的请求中重发自身状态,同时记录一条列出被省略字段的告警。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。
|
||||
存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。注册表会省略准备失败的字段,字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。基础请求连同扩展字段无法序列化时,适配器只发送基础请求并跳过接受,让贡献方在之后的请求中重发自身状态,同时记录一条列出被省略字段的告警。序列化、接受与回放降级告警每条路由只记录一次。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
|
||||
@@ -20,17 +20,30 @@ export function registerDeepSeekProvider<C extends DeepSeekConnectionOptions>(
|
||||
'options' | 'resolveAuth' | 'providerName' | 'discoverModels'>): void {
|
||||
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
|
||||
let userId: AnonymousUserId | undefined
|
||||
// These conditions repeat on every request of a route; log each first occurrence only.
|
||||
const degradedReplays = new Set<string>()
|
||||
const omittedRoutes = new Set<string>()
|
||||
const unacceptedRoutes = new Set<string>()
|
||||
const adapter = new DeepSeekAdapter({
|
||||
...dependencies,
|
||||
resolveUserId: () => userId ??= getOrCreateAnonymousUserId(),
|
||||
onReplayDegrade: ({ provider, model, reason }) => {
|
||||
const key = `${provider}/${model}\u0000${reason}`
|
||||
if (degradedReplays.has(key)) return
|
||||
degradedReplays.add(key)
|
||||
ctx.logger.warn(`llm-deepseek: unusable Messages replay state on assistant history for route "${provider}/${model}"; sending provider-neutral content (${reason})`)
|
||||
},
|
||||
onExtensionsOmitted: ({ provider, model, fields, error }) => {
|
||||
ctx.logger.warn(`llm-deepseek: sending route "${provider}/${model}" without request extension fields ${fields.join(', ')} because they failed to serialize: %o`, error)
|
||||
const route = `${provider}/${model}`
|
||||
if (omittedRoutes.has(route)) return
|
||||
omittedRoutes.add(route)
|
||||
ctx.logger.warn(`llm-deepseek: sending route "${route}" without request extension fields ${fields.join(', ')} because they failed to serialize: %o`, error)
|
||||
},
|
||||
onExtensionsUnaccepted: ({ provider, model, error }) => {
|
||||
ctx.logger.warn(`llm-deepseek: route "${provider}/${model}" request extension acceptance failed; contributors resend on a later request: %o`, error)
|
||||
const route = `${provider}/${model}`
|
||||
if (unacceptedRoutes.has(route)) return
|
||||
unacceptedRoutes.add(route)
|
||||
ctx.logger.warn(`llm-deepseek: route "${route}" request extension acceptance failed; contributors resend on a later request: %o`, error)
|
||||
},
|
||||
resolveAttachments: () => ctx.get('attachments'),
|
||||
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(
|
||||
|
||||
@@ -520,8 +520,9 @@ describe('Cordis provider composition', () => {
|
||||
const saved = JSON.stringify([assistant, result])
|
||||
const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] })))
|
||||
expect(response.assembler.finish.kind).toBe('stop')
|
||||
expect((await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] })))).assembler.finish.kind).toBe('stop')
|
||||
expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]])
|
||||
expect(http.requests).toHaveLength(1)
|
||||
expect(http.requests).toHaveLength(2)
|
||||
expect(http.requests[0]?.body.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
{ role: 'assistant', content: [
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('Messages request extensions', () => {
|
||||
expect(accept).toHaveBeenCalledTimes(failure === 'stream' ? 1 : 0)
|
||||
})
|
||||
|
||||
it('logs an acceptance failure and still streams the response', async () => {
|
||||
it('logs an acceptance failure once per route and still streams the response', async () => {
|
||||
const ctx = await boot()
|
||||
const warnings: unknown[][] = []
|
||||
ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
@@ -131,9 +131,11 @@ describe('Messages request extensions', () => {
|
||||
ctx.deepseekLlmApiExtensions.register('dsh_messages_test', {
|
||||
prepare: () => ({ value: { value: 'log' }, accept() { throw failure } }),
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(sse(textEvents))))
|
||||
const result = await assemble(ctx.llm.stream(options()))
|
||||
expect(result.assembler.finish.kind).toBe('stop')
|
||||
vi.stubGlobal('fetch', vi.fn<typeof globalThis.fetch>().mockImplementation(() => Promise.resolve(new Response(sse(textEvents)))))
|
||||
for (let request = 0; request < 2; request++) {
|
||||
const result = await assemble(ctx.llm.stream(options()))
|
||||
expect(result.assembler.finish.kind).toBe('stop')
|
||||
}
|
||||
expect(warnings).toEqual([[
|
||||
'llm-deepseek: route "deepseek-official/deepseek-v4-flash" request extension acceptance failed;'
|
||||
+ ' contributors resend on a later request: %o',
|
||||
@@ -147,9 +149,12 @@ describe('Messages request extensions', () => {
|
||||
ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
const accept = vi.fn()
|
||||
ctx.deepseekLlmApiExtensions.register('dsh_messages_test', { prepare: () => ({ value: { value: 'log' }, accept }) })
|
||||
const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(sse(textEvents)))
|
||||
const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(() => Promise.resolve(new Response(sse(textEvents))))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const { result, overflow } = await withSerializationOverflow('dsh_messages_test', () => assemble(ctx.llm.stream(options())))
|
||||
const { result, overflow } = await withSerializationOverflow('dsh_messages_test', async () => {
|
||||
await assemble(ctx.llm.stream(options()))
|
||||
return await assemble(ctx.llm.stream(options()))
|
||||
})
|
||||
expect(JSON.stringify({ dsh_messages_test: { value: 'log' } })).toBe('{"dsh_messages_test":{"value":"log"}}')
|
||||
expect(result.assembler.finish.kind).toBe('stop')
|
||||
expect(accept).not.toHaveBeenCalled()
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: dea20f16d15856af
|
||||
zh: 6548dc9da63375e5
|
||||
/deepseek-ai-dsh-plugin-package-inventory-deepseek/collection:
|
||||
en: 4ece403316723e06
|
||||
zh: 2584744f3fecadd7
|
||||
en: fd0ce77b11c992ad
|
||||
zh: 455fe92e68f7456a
|
||||
/deepseek-ai-dsh-plugin-package-inventory-deepseek/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
|
||||
@@ -37,7 +37,7 @@ Every request re-reads active non-group entries from the host Loader tree. When
|
||||
|
||||
At activation, the plugin samples the optional `ctx.pluginPackages` service. Shipped app boot mounts that service before profile Loader entries, so bare package and package-subpath identities use the same authoritative runtime resolution as their imports, including its misses, without requiring a `./package.json` export. If the service is absent, the plugin retains native Node package-search lookup for its lifetime and does not switch an existing inventory when a resolver mounts later. A low-level embedder that wants runtime resolution identities must therefore mount `PluginPackages` before both the Loader entries and this plugin.
|
||||
|
||||
Each ordinary entry uses its owning Loader tree base. A standing preset's root entries use the harness base, matching the preset Loader's deliberate bare-package override; nested includes retain their own bases. Relative and absolute modules walk to their nearest manifest; manifests without a non-blank string `name` are omitted. A missing or invalid `version` produces a name-only entry, regardless of `private`. A nested directory with its own manifest does not inherit its parent's identity. Package resolution, manifest reading, and JSON parsing failures log a warning and omit only that entry; inventory metadata does not block model requests. Entries are deduplicated by name and optional version and sorted with a locale-independent comparison; name-only entries sort before same-name versioned entries, and different versions remain separate.
|
||||
Each ordinary entry uses its owning Loader tree base. A standing preset's root entries use the harness base, matching the preset Loader's deliberate bare-package override; nested includes retain their own bases. Relative and absolute modules walk to their nearest manifest; manifests without a non-blank string `name` are omitted. A missing or invalid `version` produces a name-only entry, regardless of `private`. A nested directory with its own manifest does not inherit its parent's identity. Package resolution, manifest reading, and JSON parsing failures omit only that entry and log one warning per module; inventory metadata does not block model requests. Entries are deduplicated by name and optional version and sorted with a locale-independent comparison; name-only entries sort before same-name versioned entries, and different versions remain separate.
|
||||
|
||||
The version-1 `dsh_plugin_packages` field contains `{ name, version }` or `{ name }` entries; `version` is included only when it is a non-blank string. Disabled, pending, failed, disposed, unloading, structural `cordis:` rows, ordinary dependencies, loose files without an owning package identity, programmatically mounted child fibers, and in-memory dynamic plugins are excluded.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
|
||||
插件在激活时读取一次可选的 `ctx.pluginPackages` 服务。随附 app boot 会在 profile Loader 配置项之前挂载该服务,因此裸包和包子路径的身份使用与其 import 相同的权威 runtime resolution,包括它未命中的情况,并且无需包导出 `./package.json`。若该服务不存在,插件会在整个生命周期中保留 Node 原生包搜索,之后再挂载 resolver 也不会切换既有清单。因此,希望使用 runtime resolution 身份的底层嵌入方必须先挂载 `PluginPackages`,再挂载 Loader 配置项和本插件。
|
||||
|
||||
每个普通配置项使用其所属 Loader 树的基址。standing preset 的根配置项使用宿主基址,与 preset Loader 对裸包的显式覆写保持一致;嵌套 include 仍使用自身基址。相对与绝对模块会向上查找最近的 manifest(元数据清单);`name` 不是非空白字符串的 manifest 会被省略。`version` 缺失或无效时,只上报名称,不受 `private` 影响。拥有自身 manifest 的嵌套目录不会继承父包的身份。包解析、manifest 读取或 JSON 解析失败时,系统记录警告并只省略对应配置项;清单元数据不会阻塞模型请求。系统按名称和可选版本去重,并使用与 locale 无关的比较排序;只有名称的配置项排在同名且有版本的配置项之前,不同版本仍分开保留。
|
||||
每个普通配置项使用其所属 Loader 树的基址。standing preset 的根配置项使用宿主基址,与 preset Loader 对裸包的显式覆写保持一致;嵌套 include 仍使用自身基址。相对与绝对模块会向上查找最近的 manifest(元数据清单);`name` 不是非空白字符串的 manifest 会被省略。`version` 缺失或无效时,只上报名称,不受 `private` 影响。拥有自身 manifest 的嵌套目录不会继承父包的身份。包解析、manifest 读取或 JSON 解析失败时,系统只省略对应配置项,并对每个模块只记录一次警告;清单元数据不会阻塞模型请求。系统按名称和可选版本去重,并使用与 locale 无关的比较排序;只有名称的配置项排在同名且有版本的配置项之前,不同版本仍分开保留。
|
||||
|
||||
版本 1 的 `dsh_plugin_packages` 字段包含 `{ name, version }` 或 `{ name }` 配置项;仅当 `version` 为非空白字符串时才包含该字段。系统会排除禁用、pending、failed、disposed、unloading 状态,结构性 `cordis:` 配置项,普通依赖,没有所属包身份的松散文件,以编程方式挂载的子 fiber,以及内存动态插件。
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ function collectActivePluginPackages(
|
||||
ctx: Context,
|
||||
resolver: PackageIdentityResolver,
|
||||
hostBaseUrl: string,
|
||||
unreadable: Set<string>,
|
||||
sessionId?: string,
|
||||
): DeepSeekPluginPackageIdentity[] {
|
||||
const entries = activeEntries(ctx.loader)
|
||||
@@ -176,7 +177,10 @@ function collectActivePluginPackages(
|
||||
try {
|
||||
identity = resolver.resolve(activeEntry)
|
||||
} catch (error) {
|
||||
ctx.logger.warn('plugin-package-inventory-deepseek: omitting unreadable package identity for %s: %o', activeEntry.moduleName, error)
|
||||
if (!unreadable.has(activeEntry.moduleName)) {
|
||||
unreadable.add(activeEntry.moduleName)
|
||||
ctx.logger.warn('plugin-package-inventory-deepseek: omitting unreadable package identity for %s: %o', activeEntry.moduleName, error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (identity === undefined) continue
|
||||
@@ -196,11 +200,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (config.enabled === false) return
|
||||
const hostBaseUrl = ctx.baseUrl ?? import.meta.url
|
||||
const resolver = new PackageIdentityResolver(hostBaseUrl, ctx.get('pluginPackages'))
|
||||
// Unreadable identities are re-resolved on every request; log each module once.
|
||||
const unreadable = new Set<string>()
|
||||
ctx.deepseekLlmApiExtensions.register('dsh_plugin_packages', {
|
||||
prepare: (request) => {
|
||||
const value: DeepSeekPluginPackageInventoryExtension = {
|
||||
version: 1,
|
||||
packages: collectActivePluginPackages(ctx, resolver, hostBaseUrl, request.sessionId),
|
||||
packages: collectActivePluginPackages(ctx, resolver, hostBaseUrl, unreadable, request.sessionId),
|
||||
}
|
||||
return { value }
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -116,8 +116,10 @@ describe('DeepSeek plugin package inventory', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['invalid JSON', 'null', 'deleted'])('retains readable packages when another manifest is %s', async (kind) => {
|
||||
it.each(['invalid JSON', 'null', 'deleted'])('retains readable packages and warns at most once when another manifest is %s', async (kind) => {
|
||||
const { ctx, root } = await harness()
|
||||
const warnings: unknown[][] = []
|
||||
ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
|
||||
const bad = await packagePlugin(root, 'bad', { name: 'bad', version: '1.0.0' })
|
||||
const good = await packagePlugin(root, 'good', { name: 'good', version: '2.0.0' })
|
||||
await ctx.loader.create({ name: bad })
|
||||
@@ -125,8 +127,14 @@ describe('DeepSeek plugin package inventory', () => {
|
||||
const manifest = join(root, 'bad/package.json')
|
||||
if (kind === 'deleted') await rm(manifest)
|
||||
else await writeFile(manifest, kind === 'null' ? 'null' : '{')
|
||||
const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
|
||||
expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'good', version: '2.0.0' }])
|
||||
for (let request = 0; request < 2; request++) {
|
||||
const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
|
||||
expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'good', version: '2.0.0' }])
|
||||
}
|
||||
// A deleted manifest leaves a loose module, which is omitted without a warning.
|
||||
expect(warnings).toEqual(kind === 'deleted'
|
||||
? []
|
||||
: [['plugin-package-inventory-deepseek: omitting unreadable package identity for %s: %o', bad, expect.any(Error)]])
|
||||
})
|
||||
|
||||
it('omits a loose ESM module whose nearest manifest only marks the module type', async () => {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 1b899e63d8039643
|
||||
zh: d489461bbb618b5d
|
||||
/deepseek-ai-dsh-session-log-deepseek/request-field:
|
||||
en: 197814e2c48d05b5
|
||||
zh: f4616fb23a3c7a32
|
||||
en: 65795dc44af4b15e
|
||||
zh: 849c7d57f16f66af
|
||||
/deepseek-ai-dsh-session-log-deepseek/acceptance-and-retry:
|
||||
en: 3688592c474b3e95
|
||||
zh: 5e83afeb9aa9e9e3
|
||||
|
||||
@@ -39,7 +39,7 @@ The Web **Settings → General → Upload Session Log when using the official mo
|
||||
|
||||
For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the longest contiguous run after the watermark that fits `maxBytes`. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains `sessionFormatVersion`, a raw session header (`seedLength` is present only for a seeded Session), numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source. Surface events require `surfaceOp`, with numeric `startSeq` and `endSeq` for replacements; only system, user, and tool events may carry `sourceEventSeqs`. Assistant provider metadata stays in the embedded stream, and log-only events carry neither metadata field.
|
||||
|
||||
`maxBytes` bounds the complete serialized field in UTF-8 bytes, including the header and numeric envelope fields. A backlog above the limit drains across consecutive accepted requests, each continuing after the previous `throughSeq`. When the first pending event alone exceeds the limit, the request omits `dsh_session_log`, the plugin logs a warning, and the watermark stays before that event until `maxBytes` admits it. An event too large for the runtime to serialize at all is handled the same way, and no `maxBytes` value admits it.
|
||||
`maxBytes` bounds the complete serialized field in UTF-8 bytes, including the header and numeric envelope fields. A backlog above the limit drains across consecutive accepted requests, each continuing after the previous `throughSeq`. When the first pending event alone exceeds the limit, the request omits `dsh_session_log`, the plugin logs one warning per blocking event, and the watermark stays before that event until `maxBytes` admits it. An event too large for the runtime to serialize at all is handled the same way, and no `maxBytes` value admits it.
|
||||
|
||||
<a id="acceptance-and-retry"></a>
|
||||
## Acceptance and retry
|
||||
|
||||
@@ -39,7 +39,7 @@ Web **设置 → 通用 → 在使用官方模型 API 时上传 Session Log**
|
||||
|
||||
对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后能放进 `maxBytes` 的最长连续事件段。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR(热模块替换)会从持久日志重建它。版本 1 字段包含 `sessionFormatVersion`、原始会话 header(仅 seeded Session 携带 `seedLength`)、数值型 `afterSeq` 与 `throughSeq`,以及每个已转换为原始数值 envelope 字段的完整规范事件。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。表层事件必须携带 `surfaceOp`,替换范围使用数值型 `startSeq` 与 `endSeq`;仅 system、user 与 tool 事件可以携带 `sourceEventSeqs`。assistant 的提供方元数据保留在内嵌流中,只出现在日志中的事件不携带这两个元数据字段。
|
||||
|
||||
`maxBytes` 按 UTF-8 字节限制完整的序列化字段,包括 header 与数值型 envelope 字段。超过上限的积压会分多次已接受的请求补传,每次都从上一次的 `throughSeq` 之后继续。第一条待发事件本身就超过上限时,请求会省略 `dsh_session_log`,插件记录一条告警,水位停在该事件之前,直到 `maxBytes` 能容纳它。运行时根本无法序列化的超大事件也按同样方式处理,且任何 `maxBytes` 都无法容纳它。
|
||||
`maxBytes` 按 UTF-8 字节限制完整的序列化字段,包括 header 与数值型 envelope 字段。超过上限的积压会分多次已接受的请求补传,每次都从上一次的 `throughSeq` 之后继续。第一条待发事件本身就超过上限时,请求会省略 `dsh_session_log`,插件对每个阻塞事件只记录一次告警,水位停在该事件之前,直到 `maxBytes` 能容纳它。运行时根本无法序列化的超大事件也按同样方式处理,且任何 `maxBytes` 都无法容纳它。
|
||||
|
||||
<a id="acceptance-and-retry"></a>
|
||||
## 接受与重试
|
||||
|
||||
@@ -59,6 +59,8 @@ interface AcceptanceFold {
|
||||
}
|
||||
|
||||
const acceptanceFolds = new WeakMap<Session, AcceptanceFold>()
|
||||
/** Event each Session's upload was last reported blocked at; a block is logged once. */
|
||||
const blockedWarnings = new WeakMap<Session, SessionSeqType>()
|
||||
|
||||
/** Translate logical Session metadata to raw external request fields. */
|
||||
function wireHeader(session: Session): DeepSeekSessionLogWireHeader {
|
||||
@@ -215,7 +217,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const last = events.length === 0 ? undefined : pending[events.length - 1]
|
||||
if (last === undefined) {
|
||||
const first = pending[0]
|
||||
if (first !== undefined) {
|
||||
if (first !== undefined && blockedWarnings.get(session) !== first.seq) {
|
||||
blockedWarnings.set(session, first.seq)
|
||||
const seq = String(first.seq)
|
||||
ctx.logger.warn(Number.isFinite(candidateBytes)
|
||||
? `session-log-deepseek: event ${seq} of session "${session.id}" needs a ${String(candidateBytes)}-byte`
|
||||
|
||||
@@ -691,7 +691,7 @@ describe('byte-bounded DeepSeek session-log upload', () => {
|
||||
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1)
|
||||
const warning = `session-log-deepseek: event 0 of session "bounded-oversized" needs a ${String(limit + 1)}-byte dsh_session_log field,`
|
||||
+ ` above maxBytes ${String(limit)}; this session's upload stays at event 0 until maxBytes admits it`
|
||||
expect(warnings).toEqual([[warning], [warning]])
|
||||
expect(warnings).toEqual([[warning]])
|
||||
})
|
||||
|
||||
it('omits the field without failing the request when the next pending event exceeds the string limit', async () => {
|
||||
@@ -724,6 +724,6 @@ describe('byte-bounded DeepSeek session-log upload', () => {
|
||||
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1)
|
||||
const warning = 'session-log-deepseek: event 0 of session "bounded-string-limit" is too large to serialize into a dsh_session_log field;'
|
||||
+ " this session's upload stays at event 0"
|
||||
expect(warnings).toEqual([[warning], [warning]])
|
||||
expect(warnings).toEqual([[warning]])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user