feat(llm): allow plugins to prepare pi-ai request headers
This commit is contained in:
1 parent
dc169c71e7
commit
a05a0d02fa
15 files changed
+282
-12
No files matched your search
@@ -55,6 +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/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) |
|
||||
|
||||
@@ -57,6 +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/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) |
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
/llm-streaming/official-deepseek-request-extensions:
|
||||
en: 42bc50394b070e96
|
||||
zh: 007caa743ec3c790
|
||||
/llm-streaming/pi-ai-request-headers:
|
||||
en: 8c140f9c5a799f01
|
||||
zh: f66b895594247e92
|
||||
/llm-streaming/service-and-provider-contracts:
|
||||
en: 8dc79d4cc82824c2
|
||||
zh: 3ebabd3a117ad717
|
||||
@@ -860,6 +860,10 @@ interface LlmCallConfigAdapterDefaults {
|
||||
|
||||
The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path.
|
||||
|
||||
## Pi-ai request headers
|
||||
|
||||
`PiAiRequestHeaderContext` contains the prepared call's provider, model, resolved model `baseURL`, and optional Session id and auxiliary purpose. It is immutable, contains no messages or authentication fields, and accompanies a request-owned `Headers` object in the synchronous `llm-pi-ai/request-headers` waterfall. The [adapter's request-header documentation](../../packages/llm/llm-pi-ai/README.md#request-headers) defines contribution ownership, attribution precedence, and failure behavior. This extension point is specific to pi-ai inference requests.
|
||||
|
||||
## Service and provider contracts
|
||||
|
||||
`LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn.
|
||||
@@ -1248,4 +1252,31 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
<a id="llm-pi-ai-events"></a>
|
||||
|
||||
### `llm-pi-ai/*` events
|
||||
|
||||
<a id="llm-pi-airequest-headers--waterfall"></a>
|
||||
|
||||
#### `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.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* 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.
|
||||
* @mode waterfall
|
||||
* @param request - immutable routing metadata from the exact prepared call.
|
||||
* @param headers - fresh profile headers, shared only within this dispatch.
|
||||
* @param next - continue header preparation through the remaining listeners.
|
||||
* @returns headers passed to pi-ai after Harness attribution is applied.
|
||||
*/
|
||||
'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -866,6 +866,10 @@ interface LlmCallConfigAdapterDefaults {
|
||||
|
||||
[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。
|
||||
|
||||
## Pi-ai 请求头
|
||||
|
||||
`PiAiRequestHeaderContext` 包含已准备调用的 provider、model、解析后的模型 `baseURL`,以及可选 Session id 和辅助用途。它不可变且不含消息或认证字段,与本次请求独有的 `Headers` 一起传入同步的 `llm-pi-ai/request-headers` waterfall。[适配器的请求头文档](../../packages/llm/llm-pi-ai/README.zh.md#request-headers)定义贡献的所有权、应用标识的优先级与失败行为。此扩展点仅适用于 pi-ai 推理请求。
|
||||
|
||||
## 服务与提供方约定
|
||||
|
||||
`LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.zh.md#turn-flow)。
|
||||
@@ -1254,4 +1258,31 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
<a id="llm-pi-ai-events"></a>
|
||||
|
||||
### `llm-pi-ai/*` events
|
||||
|
||||
<a id="llm-pi-airequest-headers--waterfall"></a>
|
||||
|
||||
#### `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.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* 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.
|
||||
* @mode waterfall
|
||||
* @param request - immutable routing metadata from the exact prepared call.
|
||||
* @param headers - fresh profile headers, shared only within this dispatch.
|
||||
* @param next - continue header preparation through the remaining listeners.
|
||||
* @returns headers passed to pi-ai after Harness attribution is applied.
|
||||
*/
|
||||
'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -4998,6 +4998,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
description: 'Module replacements have finished loading.',
|
||||
parameters: [{ name: 'reloads', description: 'Replaced plugins and their module locations.' }],
|
||||
},
|
||||
{
|
||||
name: 'llm-pi-ai/request-headers',
|
||||
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.',
|
||||
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.' }],
|
||||
},
|
||||
{
|
||||
name: 'llm/adapters-updated',
|
||||
mode: 'emit',
|
||||
@@ -7370,6 +7378,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PermissionCatalog',
|
||||
declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n defaultOptions: PresetOption[];\n defaultPreset: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PiAiRequestHeaderContext',
|
||||
declaration: 'export interface PiAiRequestHeaderContext extends Readonly<Pick<GenerateOptions<string>, \'provider\' | \'model\' | \'sessionId\' | \'purpose\'>> {\n readonly baseURL: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PlatformSession',
|
||||
declaration: 'export interface PlatformSession {\n readonly origin: string;\n readonly token: string;\n readonly userId: AccountUserId | null;\n readonly embeddedPageDist?: string;\n readonly requestHeaders?: Readonly<Record<string, string>>;\n}',
|
||||
|
||||
@@ -45,8 +45,11 @@
|
||||
en: 1598ac8a8bb9b6bf
|
||||
zh: fdd089c5ae7722cd
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/design-philosophy:
|
||||
en: 8aba1113937b368a
|
||||
zh: 804a4ca672a776df
|
||||
en: db1ac8ccca2d858b
|
||||
zh: 1d7cd4153aad22b0
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/request-headers:
|
||||
en: f1577febc667c82b
|
||||
zh: 7f9dff0e191e7bd2
|
||||
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map:
|
||||
en: 64912b8a3a336419
|
||||
zh: 33a245969b4194f6
|
||||
|
||||
@@ -140,6 +140,13 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o
|
||||
|
||||
Initial prompt text and active tools use pi-ai's `Context.systemPrompt` and `Context.tools`; later system and tool updates remain in `Context.messages`. pi-ai normalizes these fields and applies the selected protocol's serialization rules.
|
||||
|
||||
<a id="request-headers"></a>
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|
||||
@@ -140,6 +140,13 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
|
||||
|
||||
初始提示词文本和可用工具分别使用 pi-ai 的 `Context.systemPrompt` 与 `Context.tools`;后续系统消息和工具更新保留在 `Context.messages` 中。pi-ai 负责规范化这些字段,并应用所选协议的序列化规则。
|
||||
|
||||
<a id="request-headers"></a>
|
||||
### 请求头
|
||||
|
||||
插件可通过同步的 `llm-pi-ai/request-headers` waterfall 贡献模型不可见的传输元数据。每次推理调用提供不可变的路由元数据(`PiAiRequestHeaderContext`)和从 profile 标头复制出的独立 `Headers`。端点来自已准备的模型,因此并发调用与配置变更不会交换请求头状态。监听器修改该副本、调用 `next()`,并在返回时释放对它的引用;所属插件卸载时,`ctx.on()` 会移除注册。Harness 的应用标识在所有贡献器之后写入,pi-ai 接收独立的标头记录。
|
||||
|
||||
该事件覆盖有无 Session id 的推理调用及辅助调用,不用于模型发现。贡献器异常或无效 HTTP 标头会在推理发送前以 `REQUEST_HEADERS` 使调用失败,不发送部分准备好的请求。贡献不会改变消息、工具 schema 或 Session 日志。直接使用 `PiAiAdapter` 的调用方可提供等效的 `prepareHeaders` 回调。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|
||||
@@ -62,6 +62,7 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { requestImageTarget, toPiContext } from './context.ts'
|
||||
import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
import type { PiAiRequestHeaderContext } from './types.ts'
|
||||
|
||||
/**
|
||||
* Initially active declaration added to an Anthropic request whose projected
|
||||
@@ -104,6 +105,16 @@ export interface PiAiAdapterOptions {
|
||||
* every request no matter how often the human signed in.
|
||||
*/
|
||||
auth: PiAiAuthInjection
|
||||
/**
|
||||
* Synchronously prepare this call's headers before SDK dispatch. The input
|
||||
* 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.
|
||||
* @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.
|
||||
*/
|
||||
prepareHeaders?: (request: PiAiRequestHeaderContext, headers: Headers) => Headers
|
||||
/** Resolve the optional durable attachment service at request time. */
|
||||
resolveAttachments?: () => AttachmentStore | undefined
|
||||
/** Bridge one attachment reference into the current model-tool execution world. */
|
||||
@@ -213,13 +224,27 @@ function reasoningInfo(
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
||||
function requestHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
|
||||
const attribution = attributionHeaders()
|
||||
const reserved = new Set(Object.keys(attribution).map(name => name.toLowerCase()))
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
|
||||
...attribution,
|
||||
/** Prepare request-owned headers without allowing contributors to replace attribution. */
|
||||
function requestHeaders(
|
||||
configured: Readonly<Record<string, string>> | undefined,
|
||||
options: GenerateOptions<string>,
|
||||
baseURL: string,
|
||||
prepare: PiAiAdapterOptions['prepareHeaders'],
|
||||
): Record<string, string> {
|
||||
try {
|
||||
const initial = new Headers(configured)
|
||||
const request: PiAiRequestHeaderContext = Object.freeze({
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
baseURL,
|
||||
...options.sessionId === undefined ? {} : { sessionId: options.sessionId },
|
||||
...options.purpose === undefined ? {} : { purpose: options.purpose },
|
||||
})
|
||||
const headers = prepare === undefined ? initial : prepare(request, initial)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,9 +444,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
|
||||
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
|
||||
signal: watchdog.signal,
|
||||
// Profile headers are deployment-owned; attribution names are
|
||||
// Harness-owned and therefore win collisions.
|
||||
headers: requestHeaders(profile.headers),
|
||||
headers: requestHeaders(profile.headers, options, model.baseUrl, this.config.prepareHeaders),
|
||||
})
|
||||
const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]()
|
||||
let exhausted = false
|
||||
|
||||
@@ -73,9 +73,11 @@ import { discoverModels } from './discovery.ts'
|
||||
import type { StoredModelDiscoveryProfile } from './discovery.ts'
|
||||
import { registerPiAiFlows } from './login.ts'
|
||||
import { providerDefaultsOf } from './provider-data.ts'
|
||||
import type { PiAiRequestHeaderContext } from './types.ts'
|
||||
|
||||
export { PiAiAdapter } from './adapter.ts'
|
||||
export type { PiAiAdapterOptions } from './adapter.ts'
|
||||
export type { PiAiRequestHeaderContext } from './types.ts'
|
||||
export { Config } from './config.ts'
|
||||
export type {
|
||||
Options,
|
||||
@@ -94,6 +96,23 @@ export { supportedProtocols } from './provider.ts'
|
||||
export const name = 'llm-pi-ai'
|
||||
export const inject = ['llm']
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* 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.
|
||||
* @mode waterfall
|
||||
* @param request - immutable routing metadata from the exact prepared call.
|
||||
* @param headers - fresh profile headers, shared only within this dispatch.
|
||||
* @param next - continue header preparation through the remaining listeners.
|
||||
* @returns headers passed to pi-ai after Harness attribution is applied.
|
||||
*/
|
||||
'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers
|
||||
}
|
||||
}
|
||||
|
||||
const NS = 'llm-pi-ai'
|
||||
|
||||
/**
|
||||
@@ -218,6 +237,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
profiles,
|
||||
resolveApiKey,
|
||||
auth,
|
||||
prepareHeaders: (request, headers) => ctx.waterfall('llm-pi-ai/request-headers', request, headers, () => headers),
|
||||
resolveAttachments: () => ctx.get('attachments'),
|
||||
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(
|
||||
attachments,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Request metadata exposed to pi-ai transport-header contributors. */
|
||||
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Model-hidden routing metadata from the exact prepared adapter call. */
|
||||
export interface PiAiRequestHeaderContext extends Readonly<Pick<GenerateOptions<string>,
|
||||
'provider' | 'model' | 'sessionId' | 'purpose'>> {
|
||||
/** Resolved endpoint of the model selected for this call. */
|
||||
readonly baseURL: string
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader, { type ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime, { createMessage, createUserMessage, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AuthorizationService from '@deepseek-ai/dsh-authorization'
|
||||
import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
|
||||
import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts'
|
||||
@@ -87,6 +88,25 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
}
|
||||
|
||||
describe('llm-pi-ai real dormant composition', () => {
|
||||
it('dispatches header contributors through the Loader-mounted adapter', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const { ctx, settingsPath } = await loadComposition()
|
||||
ctx.on('llm-pi-ai/request-headers', (request, headers, next) => {
|
||||
headers.set('x-request-session', String(request.sessionId))
|
||||
return next()
|
||||
})
|
||||
await writeFile(settingsPath, [
|
||||
'- id: llm-pi-ai', ' config:', ' providers:', ' deepseek:',
|
||||
' apiKeyEnv: PI_COMPOSITION_KEY', ` baseURL: ${server.url}`, '',
|
||||
].join('\n'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
}, { timeout: 5000 })
|
||||
const result = await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('loader-session') })
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(server.headers[0]?.['x-request-session']).toBe('loader-session')
|
||||
})
|
||||
|
||||
it('boots with zero routes and registers one the moment settings supply a profile', async () => {
|
||||
vi.stubEnv('PI_COMPOSITION_KEY', '')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Request header contributions reach the provider without crossing call lifetimes. */
|
||||
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime, { userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as PiAi from '../src/index.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
afterEach(async () => {
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, otherURL?: string): Promise<Context> {
|
||||
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' },
|
||||
}, ...otherURL === undefined ? {} : { gateway: {
|
||||
apiKeyEnv: 'PI_HEADERS_KEY', api: 'openai-completions', baseURL: otherURL, models: [{ id: 'm' }],
|
||||
} } } })
|
||||
return ctx
|
||||
}
|
||||
|
||||
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)
|
||||
const seen: PiAi.PiAiRequestHeaderContext[] = []
|
||||
const remove = ctx.on('llm-pi-ai/request-headers', (request, headers, next) => {
|
||||
seen.push(request)
|
||||
expect(Object.isFrozen(request)).toBe(true)
|
||||
if (request.sessionId === undefined) headers.delete('x-request-session')
|
||||
else headers.set('x-request-session', request.sessionId)
|
||||
headers.set('USER-AGENT', 'contributor')
|
||||
return next()
|
||||
})
|
||||
const removeSecond = ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => {
|
||||
headers.set('x-second-listener', 'yes')
|
||||
return next()
|
||||
})
|
||||
await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('one') })
|
||||
await assemble(ctx, { model: 'deepseek-flash', messages: [] })
|
||||
expect(seen).toEqual([
|
||||
{ provider: 'deepseek', model: 'deepseek-flash', baseURL: server.url, sessionId: 'one' },
|
||||
{ provider: 'deepseek', model: 'deepseek-flash', baseURL: server.url },
|
||||
])
|
||||
expect(server.headers[0]).toMatchObject({
|
||||
'x-request-session': 'one', 'x-company': 'static', 'x-second-listener': 'yes', 'user-agent': userAgent(),
|
||||
})
|
||||
expect(server.headers[1]).not.toHaveProperty('x-request-session')
|
||||
remove()
|
||||
removeSecond()
|
||||
await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('two') })
|
||||
expect(server.headers[2]).toMatchObject({ 'x-request-session': 'static-session', 'x-company': 'static' })
|
||||
expect(server.headers[2]).not.toHaveProperty('x-second-listener')
|
||||
})
|
||||
|
||||
it('uses the prepared endpoint and isolates overlapping requests', async () => {
|
||||
const first = await mockServer([{ events: textEvents, holdOpen: true }])
|
||||
const second = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(first.url, second.url)
|
||||
const seen: PiAi.PiAiRequestHeaderContext[] = []
|
||||
ctx.on('llm-pi-ai/request-headers', (request, headers, next) => {
|
||||
seen.push(request)
|
||||
headers.set('x-request-session', String(request.sessionId))
|
||||
return next()
|
||||
})
|
||||
const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-flash' })
|
||||
const iterator = prepared.stream({ ...prepared.config, messages: [], sessionId: SessionId('first') })[Symbol.asyncIterator]()
|
||||
onTestFinished(async () => { await iterator.return?.() })
|
||||
await iterator.next()
|
||||
await assemble(ctx, { provider: 'gateway', model: 'm', messages: [], sessionId: SessionId('second') })
|
||||
while (!(await iterator.next()).done) { /* Drain the first response after the second call settles. */ }
|
||||
expect(seen.map(({ provider, baseURL, sessionId }) => ({ provider, baseURL, sessionId }))).toEqual([
|
||||
{ provider: 'deepseek', baseURL: first.url, sessionId: 'first' },
|
||||
{ provider: 'gateway', baseURL: second.url, sessionId: 'second' },
|
||||
])
|
||||
expect(first.headers[0]?.['x-request-session']).toBe('first')
|
||||
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) => {
|
||||
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')
|
||||
return next()
|
||||
})
|
||||
const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
|
||||
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'REQUEST_HEADERS' } })
|
||||
expect(server.requests).toHaveLength(0)
|
||||
})
|
||||
@@ -253,6 +253,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'goal': 'goal.md',
|
||||
'schedule': 'schedule.md',
|
||||
'llm': 'llm-streaming.md',
|
||||
'llm-pi-ai': 'llm-streaming.md',
|
||||
'permission-presets': 'permission-presets.md',
|
||||
'session': 'session.md',
|
||||
'settings': 'settings.md',
|
||||
@@ -430,6 +431,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
DeepSeekLlmApiExtensionProvider: 'llm-streaming.md',
|
||||
DeepSeekLlmApiExtensionRequest: 'llm-streaming.md',
|
||||
LlmCallConfig: 'llm-streaming.md',
|
||||
PiAiRequestHeaderContext: 'llm-streaming.md',
|
||||
ConfigureCall: 'llm-streaming.md',
|
||||
LlmModelContext: 'llm-streaming.md',
|
||||
LlmModelReasoningInfo: 'llm-streaming.md',
|
||||
@@ -944,6 +946,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Entry', 'Array',
|
||||
'Plugin',
|
||||
'AbortSignal',
|
||||
'Headers',
|
||||
'AsyncIterable',
|
||||
'Context',
|
||||
'Error',
|
||||
|
||||
Reference in new issue
Block a user