From 7facb1cf65dc028b7a4c4523bb1148e33543f860 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 8 Oct 2026 11:24:00 +0800 Subject: [PATCH] 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> --- docs/deepseek-llm-api-wire-extensions.i18n.yaml | 4 ++-- docs/deepseek-llm-api-wire-extensions.md | 2 +- docs/deepseek-llm-api-wire-extensions.zh.md | 2 +- docs/subsystems/llm-streaming.md | 4 ++-- docs/subsystems/llm-streaming.zh.md | 4 ++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../README.i18n.yaml | 4 ++-- .../llm/deepseek-llm-api-extensions/README.md | 2 +- .../deepseek-llm-api-extensions/README.zh.md | 2 +- .../deepseek-llm-api-extensions/src/index.ts | 9 ++++++--- .../tests/registry.spec.ts | 12 +++++++----- packages/llm/llm-deepseek/README.i18n.yaml | 4 ++-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/host.ts | 17 +++++++++++++++-- packages/llm/llm-deepseek/tests/adapter.spec.ts | 3 ++- .../llm/llm-deepseek/tests/extensions.spec.ts | 17 +++++++++++------ .../README.i18n.yaml | 4 ++-- .../plugin-package-inventory-deepseek/README.md | 2 +- .../README.zh.md | 2 +- .../src/index.ts | 10 ++++++++-- .../tests/inventory.spec.ts | 16 ++++++++++++---- .../session-log-deepseek/README.i18n.yaml | 4 ++-- packages/session/session-log-deepseek/README.md | 2 +- .../session/session-log-deepseek/README.zh.md | 2 +- .../session/session-log-deepseek/src/index.ts | 5 ++++- .../session-log-deepseek/tests/upload.spec.ts | 4 ++-- 27 files changed, 92 insertions(+), 51 deletions(-) diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 8016eb8e68..79b48d6dc5 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -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 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index a47bb5afeb..1a30f7c463 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -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. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index b649796a05..dcccecc6a7 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -34,7 +34,7 @@ 适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。 -系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统记录告警并在该次请求中省略其字段,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。 +系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统在该次请求中省略其字段,且只记录首次失败,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。 已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。接受失败会记录告警,但不会使模型请求失败;贡献方会在之后的请求中重发未被接受的状态。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index f3ed48f184..4936ba4ae8 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -931,8 +931,8 @@ register( 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. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index eab48fa3e7..47d7c0df31 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -937,8 +937,8 @@ register( 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. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 96499efe5b..60f6ec2a9b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -991,7 +991,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async prepare(request: DeepSeekLlmApiExtensionRequest): Promise', - 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.', }, diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml index 3055091cf0..a9f16eead6 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml +++ b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml @@ -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 diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md index b3b2f5c6ed..410954fcb2 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.md +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -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. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md index 6e91f16a72..50efa0891e 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.zh.md +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -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 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 diff --git a/packages/llm/deepseek-llm-api-extensions/src/index.ts b/packages/llm/deepseek-llm-api-extensions/src/index.ts index f896caca47..e1b065088e 100644 --- a/packages/llm/deepseek-llm-api-extensions/src/index.ts +++ b/packages/llm/deepseek-llm-api-extensions/src/index.ts @@ -65,6 +65,8 @@ async function abortable(work: Promise, signal: AbortSignal): Promise { /** Registry of independently owned top-level fields for official DeepSeek requests. */ export class DeepSeekLlmApiExtensionRegistry extends Service { private readonly providers = new Map() + /** Fields whose preparation failure was already logged; later failures stay silent. */ + private readonly warnedFields = new Set() 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 diff --git a/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts b/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts index 21f37ba0c0..52d8ef2fa7 100644 --- a/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts +++ b/packages/llm/deepseek-llm-api-extensions/tests/registry.spec.ts @@ -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, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 85ef7946c7..a0254fd33e 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -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 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index c24ff1358c..6518b48d25 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -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 diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 3794e9b871..625b2b2b80 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -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` 清单;两者都留在模型输入之外。 ### 失败与恢复 diff --git a/packages/llm/llm-deepseek/src/host.ts b/packages/llm/llm-deepseek/src/host.ts index 08c1136602..39c3cba36a 100644 --- a/packages/llm/llm-deepseek/src/host.ts +++ b/packages/llm/llm-deepseek/src/host.ts @@ -20,17 +20,30 @@ export function registerDeepSeekProvider( '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() + const omittedRoutes = new Set() + const unacceptedRoutes = new Set() 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( diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 8b9f4d4e81..ae5978ae2d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -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: [ diff --git a/packages/llm/llm-deepseek/tests/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts index 9faede36b7..4b727a2ae5 100644 --- a/packages/llm/llm-deepseek/tests/extensions.spec.ts +++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts @@ -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().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().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().mockResolvedValue(new Response(sse(textEvents))) + const fetch = vi.fn().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() diff --git a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml index 7f186cd8f0..7b46321c41 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml +++ b/packages/llm/plugin-package-inventory-deepseek/README.i18n.yaml @@ -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 diff --git a/packages/llm/plugin-package-inventory-deepseek/README.md b/packages/llm/plugin-package-inventory-deepseek/README.md index 144d085c35..f9bad3fd45 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.md @@ -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. diff --git a/packages/llm/plugin-package-inventory-deepseek/README.zh.md b/packages/llm/plugin-package-inventory-deepseek/README.zh.md index b4cd3cdc87..cf9462dd02 100644 --- a/packages/llm/plugin-package-inventory-deepseek/README.zh.md +++ b/packages/llm/plugin-package-inventory-deepseek/README.zh.md @@ -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,以及内存动态插件。 diff --git a/packages/llm/plugin-package-inventory-deepseek/src/index.ts b/packages/llm/plugin-package-inventory-deepseek/src/index.ts index 81a145b8ed..3f08bd00cf 100644 --- a/packages/llm/plugin-package-inventory-deepseek/src/index.ts +++ b/packages/llm/plugin-package-inventory-deepseek/src/index.ts @@ -155,6 +155,7 @@ function collectActivePluginPackages( ctx: Context, resolver: PackageIdentityResolver, hostBaseUrl: string, + unreadable: Set, 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() 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 } }, diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 8ffd85d6d6..28793ba945 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -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 () => { diff --git a/packages/session/session-log-deepseek/README.i18n.yaml b/packages/session/session-log-deepseek/README.i18n.yaml index d604740584..250ea59464 100644 --- a/packages/session/session-log-deepseek/README.i18n.yaml +++ b/packages/session/session-log-deepseek/README.i18n.yaml @@ -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 diff --git a/packages/session/session-log-deepseek/README.md b/packages/session/session-log-deepseek/README.md index 4a2d9226d7..7fa83ee205 100644 --- a/packages/session/session-log-deepseek/README.md +++ b/packages/session/session-log-deepseek/README.md @@ -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. ## Acceptance and retry diff --git a/packages/session/session-log-deepseek/README.zh.md b/packages/session/session-log-deepseek/README.zh.md index 3c294b24d0..f5c8c847f2 100644 --- a/packages/session/session-log-deepseek/README.zh.md +++ b/packages/session/session-log-deepseek/README.zh.md @@ -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` 都无法容纳它。 ## 接受与重试 diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index 8e17edd4e7..0f197bc3f5 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -59,6 +59,8 @@ interface AcceptanceFold { } const acceptanceFolds = new WeakMap() +/** Event each Session's upload was last reported blocked at; a block is logged once. */ +const blockedWarnings = new WeakMap() /** 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` diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index 34b54d19cd..706712dd55 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -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]]) }) })