From d54475a6813981ea7c9642011b023ca05e91c96c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 29 Oct 2026 15:52:41 +0800 Subject: [PATCH] fix(llm): preserve header overrides and safe failure diagnostics --- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/llm-streaming.md | 4 +- docs/subsystems/llm-streaming.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 21 ++++++++- packages/llm/llm-pi-ai/src/index.ts | 2 + .../llm-pi-ai/tests/request-headers.spec.ts | 43 ++++++++++++++++--- 11 files changed, 73 insertions(+), 19 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cdc31a14dd..be7f32c300 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,7 +55,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:112`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | +| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6661e12178..c223d8ebdc 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -57,7 +57,7 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | -| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:112`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | +| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 5619032eb1..565da04ff2 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -1261,7 +1261,7 @@ Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) #### `llm-pi-ai/request-headers` — waterfall -Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. +Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance. ```ts cordis-catalog /** @@ -1269,6 +1269,8 @@ Prepare headers for one pi-ai inference call, excluding model discovery. Listene * Listeners run synchronously and must call next(); they may edit only the * request-owned headers and must not retain them. Throws fail the call with * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. * @mode waterfall * @param request - immutable routing metadata from the exact prepared call. * @param headers - fresh profile headers, shared only within this dispatch. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 0fb44939f9..2f360b8f8f 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -1267,7 +1267,7 @@ Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) #### `llm-pi-ai/request-headers` — waterfall -Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. +Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance. ```ts cordis-catalog /** @@ -1275,6 +1275,8 @@ Prepare headers for one pi-ai inference call, excluding model discovery. Listene * Listeners run synchronously and must call next(); they may edit only the * request-owned headers and must not retain them. Throws fail the call with * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. * @mode waterfall * @param request - immutable routing metadata from the exact prepared call. * @param headers - fresh profile headers, shared only within this dispatch. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 257431b7ed..373b67b230 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5003,7 +5003,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ mode: 'waterfall', signature: '\'llm-pi-ai/request-headers\'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers', summary: 'Prepare headers for one pi-ai inference call, excluding model discovery.', - description: 'Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward.', + description: 'Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance.', parameters: [{ name: 'request', description: 'immutable routing metadata from the exact prepared call.' }, { name: 'headers', description: 'fresh profile headers, shared only within this dispatch.' }, { name: 'next', description: 'continue header preparation through the remaining listeners.' }], }, { diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index c674d6aec1..635adc8d9f 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -48,8 +48,8 @@ en: db1ac8ccca2d858b zh: 1d7cd4153aad22b0 /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/request-headers: - en: f1577febc667c82b - zh: 7f9dff0e191e7bd2 + en: 615ca9834bf6544f + zh: 2d70b548f99000d1 /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map: en: 64912b8a3a336419 zh: 33a245969b4194f6 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 74e2727c52..1021dea71d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -143,9 +143,9 @@ Initial prompt text and active tools use pi-ai's `Context.systemPrompt` and `Con ### Request headers -Plugins can contribute model-hidden transport metadata through the synchronous `llm-pi-ai/request-headers` waterfall. Each inference call supplies immutable routing metadata (`PiAiRequestHeaderContext`) and a fresh `Headers` copy of its profile headers. The endpoint belongs to the prepared model, so concurrent calls and configuration changes cannot exchange header state. Listeners edit that copy, call `next()`, and release their references on return; `ctx.on()` removes their registration when the owning plugin unloads. Harness attribution is applied after all contributors, and pi-ai receives a detached header record. +Plugins can contribute model-hidden transport metadata through the synchronous `llm-pi-ai/request-headers` waterfall. Each inference call supplies immutable routing metadata (`PiAiRequestHeaderContext`) and a fresh `Headers` copy of its profile headers; when configured names differ only in capitalization, the last entry wins. The endpoint belongs to the prepared model, so concurrent calls and configuration changes cannot exchange header state. Listeners edit that copy, call `next()`, and release their references on return; `ctx.on()` removes their registration when the owning plugin unloads. Harness attribution is applied after all contributors, and pi-ai receives a detached header record. -This event covers inference calls with or without a Session id, including auxiliary calls, but excludes model discovery. A contributor exception or invalid HTTP header fails the call with `REQUEST_HEADERS` before inference dispatch; no partially prepared request is sent. Contributions do not change messages, tool schemas, or the Session log. Direct `PiAiAdapter` consumers can supply the equivalent `prepareHeaders` callback. +This event covers inference calls with or without a Session id, including auxiliary calls, but excludes model discovery. A contributor exception or invalid HTTP header fails the call with `REQUEST_HEADERS` before inference dispatch; no partially prepared request is sent. The failure identifies the provider and includes the contributor's top-level error message, which must exclude credentials and header values. Native header validation can echo values in a `TypeError`, so TypeError details and non-Error throws receive safe guidance instead; nested causes remain attached to the thrown error but are not copied into the public failure. Contributions do not change messages, tool schemas, or the Session log. Direct `PiAiAdapter` consumers can supply the equivalent `prepareHeaders` callback. ### Source map diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 4f728a077f..1526faa4b8 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -143,9 +143,9 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的 ### 请求头 -插件可通过同步的 `llm-pi-ai/request-headers` waterfall 贡献模型不可见的传输元数据。每次推理调用提供不可变的路由元数据(`PiAiRequestHeaderContext`)和从 profile 标头复制出的独立 `Headers`。端点来自已准备的模型,因此并发调用与配置变更不会交换请求头状态。监听器修改该副本、调用 `next()`,并在返回时释放对它的引用;所属插件卸载时,`ctx.on()` 会移除注册。Harness 的应用标识在所有贡献器之后写入,pi-ai 接收独立的标头记录。 +插件可通过同步的 `llm-pi-ai/request-headers` waterfall 贡献模型不可见的传输元数据。每次推理调用提供不可变的路由元数据(`PiAiRequestHeaderContext`)和从 profile 标头复制出的独立 `Headers`;配置名称仅大小写不同时,以最后一个条目为准。端点来自已准备的模型,因此并发调用与配置变更不会交换请求头状态。监听器修改该副本、调用 `next()`,并在返回时释放对它的引用;所属插件卸载时,`ctx.on()` 会移除注册。Harness 的应用标识在所有贡献器之后写入,pi-ai 接收独立的标头记录。 -该事件覆盖有无 Session id 的推理调用及辅助调用,不用于模型发现。贡献器异常或无效 HTTP 标头会在推理发送前以 `REQUEST_HEADERS` 使调用失败,不发送部分准备好的请求。贡献不会改变消息、工具 schema 或 Session 日志。直接使用 `PiAiAdapter` 的调用方可提供等效的 `prepareHeaders` 回调。 +该事件覆盖有无 Session id 的推理调用及辅助调用,不用于模型发现。贡献器异常或无效 HTTP 标头会在推理发送前以 `REQUEST_HEADERS` 使调用失败,不发送部分准备好的请求。失败信息包含 provider 和贡献器的顶层错误消息,该消息必须排除凭据与标头值。原生标头校验可能在 `TypeError` 中回显值,因此 TypeError 详情与非 Error 抛出值改用安全的排查提示;嵌套 cause 仍附在抛出的错误上,但不会复制到公开失败信息中。贡献不会改变消息、工具 schema 或 Session 日志。直接使用 `PiAiAdapter` 的调用方可提供等效的 `prepareHeaders` 回调。 ### 源码地图 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 21b9a74b05..4c3bdce845 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -110,6 +110,8 @@ export interface PiAiAdapterOptions { * contains a fresh copy of profile headers; attribution is enforced afterward. * Omission preserves profile headers. Throws fail the call with REQUEST_HEADERS * before inference I/O. Do not retain or mutate headers after returning. + * Non-TypeError messages become public diagnostics and must exclude secrets; + * TypeError details and non-Error values are replaced with safe guidance. * @param request - immutable routing metadata from the prepared call. * @param headers - request-owned headers initialized from the profile. * @returns headers to pass to pi-ai after applying Harness attribution. @@ -224,6 +226,16 @@ function reasoningInfo( } } +/** Native header TypeErrors can echo credentials; other plugin errors own their public message. */ +function requestHeaderDiagnostic(error: unknown): string { + if (error instanceof TypeError) { + return 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration' + } + return error instanceof Error + ? error.message + : 'the contributing plugin threw a non-Error value; throw an Error with a safe diagnostic' +} + /** Prepare request-owned headers without allowing contributors to replace attribution. */ function requestHeaders( configured: Readonly> | undefined, @@ -232,7 +244,9 @@ function requestHeaders( prepare: PiAiAdapterOptions['prepareHeaders'], ): Record { try { - const initial = new Headers(configured) + const initial = new Headers() + // Profile aliases overwrite case-insensitively; the Headers constructor appends them. + for (const [name, value] of Object.entries(configured ?? {})) initial.set(name, value) const request: PiAiRequestHeaderContext = Object.freeze({ provider: options.provider, model: options.model, @@ -244,7 +258,10 @@ function requestHeaders( for (const [name, value] of Object.entries(attributionHeaders())) headers.set(name, value) return Object.fromEntries(headers) } catch (error) { - throw new LlmError('pi-ai request header preparation failed', 'REQUEST_HEADERS', { cause: error }) + throw new LlmError( + `pi-ai request header preparation failed for provider "${options.provider}": ${requestHeaderDiagnostic(error)}`, + 'REQUEST_HEADERS', { cause: error }, + ) } } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 728abae408..5a53e7106a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -103,6 +103,8 @@ declare module '@deepseek-ai/cordis' { * Listeners run synchronously and must call next(); they may edit only the * request-owned headers and must not retain them. Throws fail the call with * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. * @mode waterfall * @param request - immutable routing metadata from the exact prepared call. * @param headers - fresh profile headers, shared only within this dispatch. diff --git a/packages/llm/llm-pi-ai/tests/request-headers.spec.ts b/packages/llm/llm-pi-ai/tests/request-headers.spec.ts index b3881e6cae..7b26c39317 100644 --- a/packages/llm/llm-pi-ai/tests/request-headers.spec.ts +++ b/packages/llm/llm-pi-ai/tests/request-headers.spec.ts @@ -13,20 +13,40 @@ afterEach(async () => { vi.unstubAllEnvs() }) -async function harness(baseURL: string, otherURL?: string): Promise { +async function harness(baseURL: string, otherURL?: string, headers?: Record): Promise { vi.stubEnv('PI_HEADERS_KEY', 'fixture-key') const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(LlmRuntime) await ctx.plugin(PiAi, { providers: { deepseek: { apiKeyEnv: 'PI_HEADERS_KEY', baseURL, - headers: { 'X-Company': 'static', 'X-Request-Session': 'static-session' }, + headers: { 'X-Company': 'static', 'X-Request-Session': 'static-session', ...headers }, }, ...otherURL === undefined ? {} : { gateway: { apiKeyEnv: 'PI_HEADERS_KEY', api: 'openai-completions', baseURL: otherURL, models: [{ id: 'm' }], } } } }) return ctx } +it.each([false, true])('keeps the last configured value of case-insensitive headers with contributor=%s', async (contribute) => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, undefined, { + Authorization: 'Bearer old-fixture', authorization: 'Bearer new-fixture', + 'X-Tenant': 'old-tenant', 'x-tenant': 'new-tenant', + }) + if (contribute) { + ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => { + expect(headers.get('authorization')).toBe('Bearer new-fixture') + headers.set('X-Tenant', 'contributed-tenant') + return next() + }) + } + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.headers[0]?.authorization).toBe('Bearer new-fixture') + expect(server.headers[0]?.['x-tenant']).toBe(contribute ? 'contributed-tenant' : 'new-tenant') + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) +}) + it('composes request-local headers, enforces attribution, and removes disposed listeners', async () => { const server = await mockServer(Array.from({ length: 3 }, () => ({ events: textEvents }))) const ctx = await harness(server.url) @@ -84,15 +104,26 @@ it('uses the prepared endpoint and isolates overlapping requests', async () => { expect(second.headers[0]?.['x-request-session']).toBe('second') }) -it.each(['throw', 'invalid header'] as const)('fails before HTTP dispatch when a contributor reports %s', async (failure) => { +it.each([ + ['throw', 'fixture header failure'], + ['invalid name', 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration'], + ['invalid value', 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration'], + ['non-Error', 'the contributing plugin threw a non-Error value; throw an Error with a safe diagnostic'], +] as const)('reports a safe reason before HTTP dispatch when a contributor reports %s', async (failure, detail) => { const server = await mockServer([]) const ctx = await harness(server.url) ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => { - if (failure === 'throw') throw new Error('fixture header failure') - headers.set('invalid\nname', 'value') + if (failure === 'throw') throw new Error('fixture header failure', { cause: new Error('private-cause-fixture') }) + if (failure === 'non-Error') throw 'private-thrown-fixture' + if (failure === 'invalid name') headers.set('invalid\nname', 'value') + else headers.set('authorization', 'Bearer private-header-fixture\nsuffix') return next() }) const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'REQUEST_HEADERS' } }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { + code: 'REQUEST_HEADERS', + message: `pi-ai request header preparation failed for provider "deepseek": ${detail}`, + } }) + expect(JSON.stringify(result.finish)).not.toContain('private-') expect(server.requests).toHaveLength(0) })