Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/README.md
|
||||
README.md: 92d9fbfa2b8c8db4700562009db49229b2189ab3
|
||||
README.zh.md: 5c6e7aad1db6511bdb660b86e257652128db131f
|
||||
README.md: 15f90024abf3256560d67099af79be7d4e0ee310
|
||||
README.zh.md: 5f733808ddd591f10e4f2ed437a3f59db3bfb679
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages.
|
||||
The LLM seam and its provider adapters. The `llm` package owns both the Service Definition and Consumer roles: the abstract service, content-block vocabulary, and stream-chunk assembler. Provider adapters register on `ctx.llm`. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
@@ -13,3 +13,5 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
|
||||
| [`llm-pi-ai/`](llm-pi-ai/README.md) | Multi-provider pi-ai adapter | registers on `ctx.llm` |
|
||||
|
||||
Adapters register provider routes on the seam; retry and token measurement remain separate consumers. The child READMEs own routing, metadata, replay, and provider-wire details; the [LLM architecture decisions](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) own the rationale.
|
||||
|
||||
The subsystem reference — messages and blocks, the model request, the `StreamChunk` protocol, the adapter contract — is [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.md) (token measurement: [token-meter.md](../../docs/subsystems/token-meter.md)); see the [twin adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md), [replay token meter](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md), and [routed model context](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) Agent Notes.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
LLM(大语言模型)seam 及其提供方适配器。接口包(`llm`)负责抽象服务、内容块词汇和流式分片组装器;适配器是注册到 `ctx.llm` 的具体实现。这些全是**产品**包。
|
||||
LLM(大语言模型)seam 及其提供方适配器。`llm` 包同时承担 Service Definition 和 Consumer 角色:抽象服务、内容块词汇和流式分片组装器。提供方适配器注册到 `ctx.llm`。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
@@ -13,3 +13,5 @@ LLM(大语言模型)seam 及其提供方适配器。接口包(`llm`)负
|
||||
| [`llm-pi-ai/`](llm-pi-ai/README.md) | 多提供方 pi-ai 适配器 | 注册到 `ctx.llm` |
|
||||
|
||||
适配器在 seam 上注册提供方路由;重试与 token 测量仍是独立消费方。子 README 负责路由、元数据、回放和提供方协议细节;[LLM 架构决策](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)负责设计原理。
|
||||
|
||||
子系统参考——消息与内容块、模型请求、`StreamChunk` 协议、适配器约定——见 [docs/subsystems/llm-streaming.md](../../docs/subsystems/llm-streaming.md)(token 计量:[token-meter.md](../../docs/subsystems/token-meter.md));另见[孪生适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)、[回放 token 计量](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md)与[按路由模型上下文](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) Agent Note。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
|
||||
README.md: 6ad674ebdf8da4fd927a9499e80e06462d3c0dfb
|
||||
README.zh.md: 02a192e38aa19b24a34e83b3ddb77d786bbace0b
|
||||
README.md: a21f9f0464e9d43d2091bd446eb123d4d0990c3d
|
||||
README.zh.md: 2c45f2144694785590b339642cb62b62a1aa4198
|
||||
|
||||
@@ -45,7 +45,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
|
||||
`thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults.
|
||||
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries.
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries.
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
包根入口导出 Cordis 插件契约与 `DeepSeekAdapter`;协议序列化、SSE 解析与分片转换 helper 不属于该根契约。
|
||||
包根入口导出 Cordis 插件约定与 `DeepSeekAdapter`;协议序列化、SSE 解析与分片转换 helper 不属于该根约定。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -45,7 +45,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
`thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。
|
||||
|
||||
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。
|
||||
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
@@ -60,7 +60,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
## 应用归因
|
||||
|
||||
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
|
||||
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器约定(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
|
||||
|
||||
## 协议格式说明
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface DeepSeekConnectionOptions {
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */
|
||||
/** Constructor options for {@link DeepSeekAdapter}: the two resolution hooks the plugin owns. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Current validated connection facts; called once per operation. */
|
||||
options: () => DeepSeekConnectionOptions
|
||||
@@ -221,7 +221,13 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
? consumer.signal
|
||||
: AbortSignal.any([options.signal, consumer.signal])
|
||||
using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
|
||||
const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]()
|
||||
const iterator = this.request(
|
||||
options,
|
||||
watchdog.signal,
|
||||
connection,
|
||||
apiKey,
|
||||
() => { watchdog.pulse() },
|
||||
)[Symbol.asyncIterator]()
|
||||
let exhausted = false
|
||||
try {
|
||||
while (true) {
|
||||
@@ -262,6 +268,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
signal: AbortSignal,
|
||||
connection: DeepSeekConnectionOptions,
|
||||
apiKey: string,
|
||||
onComment: () => void,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, connection.defaults)
|
||||
// Prepared outside the try so the TRANSPORT label below covers exactly the
|
||||
@@ -296,7 +303,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
// fetch wraps every transport failure (DNS, refused connection, TLS,
|
||||
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
|
||||
// lives on `cause`. Wrapping with the endpoint and chaining the cause
|
||||
// lets `errorChain` render the full diagnosis at every reporting seam.
|
||||
// lets `errorChain` render the full diagnosis at every reporting boundary.
|
||||
throw new LlmError(
|
||||
`DeepSeek API request to ${connection.baseURL} failed`,
|
||||
'TRANSPORT',
|
||||
@@ -327,6 +334,6 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
|
||||
}
|
||||
|
||||
yield* translate(parseSse(response.body))
|
||||
yield* translate(parseSse(response.body, onComment))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Decode an SSE byte stream into event `data` payloads. Framing — chunk
|
||||
* reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping,
|
||||
* multi-`data:` joining — is `eventsource-parser`'s; this module keeps only
|
||||
* the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns
|
||||
* final flushing, and EOF before it raises {@link LlmError}. Framing is
|
||||
* spec-strict: an event dispatches only on its blank-line terminator, so an
|
||||
* unterminated tail at EOF is truncation, not a flushable payload.
|
||||
* multi-`data:` joining — is `eventsource-parser`'s. Comments are reported
|
||||
* only through an optional transport-activity callback. This module keeps the
|
||||
* DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final
|
||||
* flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict:
|
||||
* an event dispatches only on its blank-line terminator, so an unterminated
|
||||
* tail at EOF is truncation, not a flushable payload.
|
||||
*
|
||||
* @module dsh-llm-deepseek/sse
|
||||
*/
|
||||
@@ -21,12 +22,16 @@ export const DONE = '[DONE]'
|
||||
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
||||
* without it (truncated response — the model call cannot be trusted).
|
||||
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
||||
* @param onComment - optional transport-activity callback; comments never enter the yielded payload stream.
|
||||
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
|
||||
*/
|
||||
export async function* parseSse(stream: ReadableStream<BufferSource>): AsyncGenerator<string> {
|
||||
export async function* parseSse(
|
||||
stream: ReadableStream<BufferSource>,
|
||||
onComment?: (comment: string) => void,
|
||||
): AsyncGenerator<string> {
|
||||
const events = stream
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new EventSourceParserStream())
|
||||
.pipeThrough(new EventSourceParserStream({ onComment }))
|
||||
for await (const { data } of events) {
|
||||
yield data
|
||||
if (data === DONE) return
|
||||
|
||||
@@ -548,6 +548,40 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an idle provider read alive through SSE comments', async () => {
|
||||
vi.useFakeTimers()
|
||||
const encoder = new TextEncoder()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75)
|
||||
setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150)
|
||||
setTimeout(() => {
|
||||
controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join('')))
|
||||
controller.close()
|
||||
}, 225)
|
||||
},
|
||||
})
|
||||
return Promise.resolve(new Response(body, { status: 200 }))
|
||||
})
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 })
|
||||
try {
|
||||
const chunks: string[] = []
|
||||
const drain = (async () => {
|
||||
for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) {
|
||||
chunks.push(chunk.type)
|
||||
}
|
||||
})()
|
||||
await vi.advanceTimersByTimeAsync(75)
|
||||
await vi.advanceTimersByTimeAsync(75)
|
||||
await vi.advanceTimersByTimeAsync(75)
|
||||
await expect(drain).resolves.toBeUndefined()
|
||||
expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin registration and config', () => {
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
})
|
||||
|
||||
it('keeps a stored key writable and rotatable across a real restart', async () => {
|
||||
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
|
||||
// No ambient DEEPSEEK_API_KEY: the shipped surfaces do not hoist
|
||||
// the credentials document into process.env, so a stored key must stay file-sourced.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const first = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
|
||||
@@ -31,6 +31,16 @@ describe('parseSse', () => {
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('reports comments out of band without yielding them', async () => {
|
||||
const comments: string[] = []
|
||||
const events = await collect(parseSse(
|
||||
bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'),
|
||||
(comment) => { comments.push(comment) },
|
||||
))
|
||||
expect(comments).toEqual(['keep-alive'])
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('stops yielding after DONE even when more data follows', async () => {
|
||||
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
|
||||
expect(events).toEqual([DONE])
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
|
||||
README.md: eb67ce889193aadbd694d7aae53e47c7d20703be
|
||||
README.zh.md: b4b3e3c208702fa10e5f434a70608702d0576fbd
|
||||
README.md: 7151fdf5b63f48e625d00a92dc42aa24b7de2f31
|
||||
README.zh.md: 0bfd5c706e01dd4448edb9cf0eec812831f68093
|
||||
|
||||
@@ -133,9 +133,9 @@ Credentials never enter that collection. The harness resolves a route's key thro
|
||||
|
||||
The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
|
||||
|
||||
Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
|
||||
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
|
||||
|
||||
If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provenance provider/model mismatches, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
|
||||
If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provider/model mismatches between the message and replay state, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
|
||||
|
||||
## Vocabulary differences
|
||||
|
||||
@@ -194,4 +194,4 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
|
||||
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step seam; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step extension point; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并针对该路由已配置的 catalog 解析 `GenerateOptions.model`。点名了已安装 pi-ai 提供方的路由会继承其端点、协议格式与模型 catalog 作为默认值,并逐字段覆盖;pi-ai 未提供的路由则整体声明出来,因此接入 OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,都属于配置而非改代码。
|
||||
|
||||
包(package)根入口导出 Cordis 插件契约、`PiAiAdapter` 与 `supportedProtocols()`;profile 解析、catalog 物化、提供方构造、回放转换和流转换保留在包内部。
|
||||
包根入口导出 Cordis 插件约定、`PiAiAdapter` 与 `supportedProtocols()`;profile 解析、catalog 物化、提供方构造、回放转换和流转换保留在包内部。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -133,9 +133,9 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。
|
||||
|
||||
成功的 assistant 响应会在自身持久提供方/模型溯源旁存储经版本化的无损 JSON 回放状态。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
|
||||
成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
|
||||
|
||||
如果 listener 改写已组装 assistant 内容,loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、溯源提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败。
|
||||
如果 listener 改写已组装 assistant 内容,loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、消息与回放状态之间的提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败。
|
||||
|
||||
## 词汇差异
|
||||
|
||||
@@ -194,4 +194,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤 seam 上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤扩展点上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
|
||||
@@ -61,7 +61,7 @@ interface PiAiSnapshot {
|
||||
models: Models
|
||||
}
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
|
||||
/** Constructor options for {@link PiAiAdapter}: the two resolution hooks the plugin owns. */
|
||||
export interface PiAiAdapterOptions {
|
||||
/** Current validated profiles by provider route; called once per operation. */
|
||||
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
|
||||
|
||||
@@ -62,8 +62,7 @@ type PiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFormat']>
|
||||
|
||||
/**
|
||||
* pi-ai thinking formats a profile cannot name: both drive the request through
|
||||
* `chatTemplateKwargs`, which this configuration does not expose, so offering
|
||||
* them would hand back a format with nothing to say.
|
||||
* `chatTemplateKwargs`, which this configuration does not expose.
|
||||
*/
|
||||
type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
|
||||
|
||||
@@ -467,10 +466,8 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
||||
// The installed entry lays the floor, and the fields below override it.
|
||||
// Enumerating instead would silently drop every `Model` field this
|
||||
// package does not model — reasoning-level spellings, compatibility
|
||||
// quirks, model headers, and whatever a pi-ai upgrade adds next. That is
|
||||
// not hypothetical: `headers` reached this file only after an nvidia
|
||||
// route lost it, and a rebuild keeps re-earning that bug on every
|
||||
// upgrade.
|
||||
// quirks, model headers, and whatever a pi-ai upgrade adds next. Spread,
|
||||
// never enumerate.
|
||||
...base,
|
||||
id: entry.id,
|
||||
name: entry.name ?? base?.name ?? entry.id,
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('toPiContext', () => {
|
||||
expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop')
|
||||
})
|
||||
|
||||
it('preserves model provenance for foreign assistant messages without replay state', () => {
|
||||
it('preserves provider and model for foreign assistant messages without replay state', () => {
|
||||
const context = toPiContext({
|
||||
provider: 'openai',
|
||||
model: 'new-model',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
|
||||
README.md: e6e56ec44032d714393c6fcc1c42d7271017a294
|
||||
README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435
|
||||
README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b
|
||||
README.zh.md: bb8c5b50efddf3def0047f411285b57c3c544f95
|
||||
|
||||
@@ -8,9 +8,9 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r
|
||||
|
||||
Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction.
|
||||
|
||||
Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
|
||||
Before waiting, the plugin appends a non-surface `llm/retry` event with the shared `retryId`, provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. When the wait completes, the plugin appends `llm/retry-started` with the same `retryId`, turn, step, and retry number immediately before returning `{ kind: 'retry' }`; cancellation during backoff writes no started event. The loop then closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
|
||||
|
||||
The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
|
||||
The separately published `./invariant` companion checks that every scheduled retry names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. It also requires each `llm/retry-started` event to name one prior scheduled attempt with the same `retryId`, turn, step, and retry number, and rejects repeated started events. Full jitter may schedule zero milliseconds at its lower boundary.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
|
||||
|
||||
等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
|
||||
等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`、提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
|
||||
|
||||
单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。
|
||||
单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。完整 jitter 可以在下界调度为零毫秒。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
@@ -40,6 +41,7 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
|
||||
13
packages/llm/llm-retry/src/brand.ts
Normal file
13
packages/llm/llm-retry/src/brand.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Stable identity shared by every attempt in one request-step retry chain. */
|
||||
export type RetryId = Branded<'RetryId'>
|
||||
|
||||
/**
|
||||
* Brand an implementation-minted retry-chain identity.
|
||||
* @param id - opaque retry identity.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function RetryId(id: string): RetryId {
|
||||
return id as RetryId
|
||||
}
|
||||
@@ -1,43 +1,21 @@
|
||||
/**
|
||||
* Provider-routed model-request retry policy on the agent loop's request
|
||||
* recovery seam. Each scheduled retry is durable before its cancellable wait.
|
||||
* recovery extension point. Each scheduled retry is durable before its cancellable wait.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-retry
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context, Events } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { RetryId } from './brand.ts'
|
||||
import type { LlmRetryEventData } from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
|
||||
'llm/retry': {
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
mode: 'normal'
|
||||
policyKey: string
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: LlmFailure
|
||||
} | {
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
mode: 'always'
|
||||
policyKey: string
|
||||
retry: number
|
||||
delayMs: number
|
||||
failure: LlmFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { LlmRetryEventData } from './types.ts'
|
||||
export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts'
|
||||
export { RetryId } from './brand.ts'
|
||||
|
||||
export const name = 'llm-retry'
|
||||
export const inject = ['agents']
|
||||
@@ -57,7 +35,7 @@ function validateConfig(config: Config): void {
|
||||
throw new Error(`llm-retry: unknown key "${key}"`)
|
||||
}
|
||||
|
||||
/** Non-serializable seams used to make timing policy deterministic in tests. */
|
||||
/** Non-serializable hooks used to make timing policy deterministic in tests. */
|
||||
export interface RetryInternals {
|
||||
/** Random sample in the inclusive zero-to-one range used for jitter. */
|
||||
random?: () => number
|
||||
@@ -116,7 +94,7 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean
|
||||
* Install provider-routed normal or unbounded request recovery.
|
||||
* @param ctx - plugin context that owns the listener and active waits.
|
||||
* @param config - empty executor config; provider registrations own policy.
|
||||
* @param internals - non-serializable deterministic seams for tests.
|
||||
* @param internals - non-serializable deterministic hooks for tests.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
|
||||
validateConfig(config)
|
||||
@@ -139,13 +117,15 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
policy: ResolvedRetryPolicy,
|
||||
policyKey: string,
|
||||
retry: number,
|
||||
retryId: RetryId,
|
||||
delayMs: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<RequestErrorAction> {
|
||||
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
||||
if (fusedSignal.aborted) return
|
||||
const eventData = policy.mode === 'normal'
|
||||
const eventData: LlmRetryEventData = policy.mode === 'normal'
|
||||
? {
|
||||
retryId,
|
||||
turn,
|
||||
step,
|
||||
provider,
|
||||
@@ -157,6 +137,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
failure,
|
||||
}
|
||||
: {
|
||||
retryId,
|
||||
turn,
|
||||
step,
|
||||
provider,
|
||||
@@ -168,6 +149,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
}
|
||||
agent.session.append('llm/retry', eventData)
|
||||
if (!await cancellableDelay(delayMs, fusedSignal)) return
|
||||
agent.session.append('llm/retry-started', { retryId, turn, step, retry })
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
|
||||
@@ -207,6 +189,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
const previousRetry = priorPolicyRetry?.data.retry ?? 0
|
||||
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
|
||||
const retry = previousRetry + 1
|
||||
const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID())
|
||||
let delayMs: number
|
||||
if (failure.providerRetryAfterMs !== undefined
|
||||
&& Number.isFinite(failure.providerRetryAfterMs)
|
||||
@@ -221,7 +204,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
delayMs = localDelay(policy, retry, random)
|
||||
}
|
||||
|
||||
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal)
|
||||
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal)
|
||||
}
|
||||
|
||||
const disposeListener = ctx.on('agent/request-error', (
|
||||
|
||||
@@ -47,7 +47,10 @@ function validateRetry(
|
||||
event: SessionEvent<'llm/retry'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data
|
||||
const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data
|
||||
if (typeof retryId !== 'string' || retryId.length === 0) {
|
||||
fail('llm/retry retryId must be a non-empty string')
|
||||
}
|
||||
const failure: unknown = event.data.failure
|
||||
validateFailure(failure, fail)
|
||||
if (!Number.isSafeInteger(retry) || retry < 1) {
|
||||
@@ -110,12 +113,43 @@ function validateRetry(
|
||||
if (retry !== expectedRetry) {
|
||||
fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`)
|
||||
}
|
||||
if (priorPolicyRetry !== undefined && priorPolicyRetry.data.retryId !== retryId) {
|
||||
fail('llm/retry must preserve retryId across one provider-policy chain')
|
||||
}
|
||||
if (priorPolicyRetry === undefined && history.some(prior =>
|
||||
(prior.type === 'llm/retry' || prior.type === 'llm/retry-started')
|
||||
&& prior.data.retryId === retryId)) {
|
||||
fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one wait-complete transition against its scheduled attempt. */
|
||||
function validateStarted(
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'llm/retry-started'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const { retryId, turn, step, retry } = event.data
|
||||
if (typeof retryId !== 'string' || retryId.length === 0) {
|
||||
fail('llm/retry-started retryId must be a non-empty string')
|
||||
}
|
||||
const scheduled = history.findLast((prior): prior is SessionEvent<'llm/retry'> =>
|
||||
prior.type === 'llm/retry' && prior.data.retryId === retryId && prior.data.retry === retry)
|
||||
if (scheduled === undefined) fail('llm/retry-started pairs no prior scheduled attempt')
|
||||
if (scheduled.data.turn !== turn || scheduled.data.step !== step) {
|
||||
fail('llm/retry-started turn/step must match its scheduled attempt')
|
||||
}
|
||||
if (history.some(prior => prior.type === 'llm/retry-started'
|
||||
&& prior.data.retryId === retryId && prior.data.retry === retry)) {
|
||||
fail('llm/retry-started repeats one scheduled attempt')
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate every retry record already present in one loaded session. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const [index, event] of session.events.entries()) {
|
||||
if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail)
|
||||
else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +161,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type === 'llm/retry') validateRetry(session.events, event, fail)
|
||||
else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { LlmFailure } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { RetryId } from './brand.ts'
|
||||
|
||||
export type { RetryId }
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
|
||||
'llm/retry': LlmRetryEventData
|
||||
/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
|
||||
'llm/retry-started': LlmRetryStartedEventData
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable payload recorded before one provider-routed model-request retry wait. */
|
||||
export type LlmRetryEventData =
|
||||
| {
|
||||
retryId: RetryId
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
@@ -13,7 +26,9 @@ export type LlmRetryEventData =
|
||||
delayMs: number
|
||||
failure: LlmFailure
|
||||
}
|
||||
|
||||
| {
|
||||
retryId: RetryId
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
@@ -23,3 +38,11 @@ export type LlmRetryEventData =
|
||||
delayMs: number
|
||||
failure: LlmFailure
|
||||
}
|
||||
|
||||
/** Durable transition recorded after one retry delay completes. */
|
||||
export interface LlmRetryStartedEventData {
|
||||
retryId: RetryId
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
|
||||
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
|
||||
import { providerForOpenStep } from '../src/history.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
@@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) {
|
||||
|
||||
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
|
||||
const normal = {
|
||||
retryId: RetryId('normal-retry-chain'),
|
||||
provider: 'mock',
|
||||
mode: 'normal' as const,
|
||||
policyKey: 'normal-policy',
|
||||
@@ -47,6 +49,7 @@ const normal = {
|
||||
failure,
|
||||
}
|
||||
const always = {
|
||||
retryId: RetryId('always-retry-chain'),
|
||||
provider: 'mock',
|
||||
mode: 'always' as const,
|
||||
policyKey: 'always-policy',
|
||||
@@ -130,6 +133,7 @@ describe('llm-retry invariants', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/],
|
||||
['retry-zero', { ...normal, retry: 0 }, /positive safe integer/],
|
||||
['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/],
|
||||
['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/],
|
||||
@@ -212,10 +216,65 @@ describe('llm-retry invariants', () => {
|
||||
reset.append('step/end', { turn: 1, step: 1 })
|
||||
reset.append('step/start', { turn: 1, step: 2 })
|
||||
expect(() => {
|
||||
reset.append('llm/retry', { turn: 1, step: 2, ...normal })
|
||||
reset.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
...normal,
|
||||
retryId: RetryId('reset-step-2-retry-chain'),
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps one retry identity per provider-policy chain', async () => {
|
||||
const ctx = await setup()
|
||||
const changed = openStep(ctx, 'retry-invariant-changed-chain-id')
|
||||
changed.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => changed.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
...normal,
|
||||
retry: 2,
|
||||
retryId: RetryId('changed-retry-chain'),
|
||||
})).toThrow(/must preserve retryId/)
|
||||
|
||||
const reused = openStep(ctx, 'retry-invariant-reused-chain-id')
|
||||
reused.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => reused.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
...always,
|
||||
retryId: normal.retryId,
|
||||
})).toThrow(/already owned by another chain/)
|
||||
})
|
||||
|
||||
it('validates retry-started correlation and uniqueness', async () => {
|
||||
const ctx = await setup()
|
||||
const empty = openStep(ctx, 'retry-started-empty-id')
|
||||
expect(() => empty.append('llm/retry-started', {
|
||||
retryId: RetryId(''), turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/retryId must be a non-empty string/)
|
||||
|
||||
const missing = openStep(ctx, 'retry-started-missing-schedule')
|
||||
expect(() => missing.append('llm/retry-started', {
|
||||
retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/pairs no prior scheduled attempt/)
|
||||
|
||||
const mismatch = openStep(ctx, 'retry-started-location-mismatch')
|
||||
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
expect(() => mismatch.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 2, step: 1, retry: 1,
|
||||
})).toThrow(/turn\/step must match/)
|
||||
|
||||
const repeated = openStep(ctx, 'retry-started-repeated')
|
||||
repeated.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
repeated.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})
|
||||
expect(() => repeated.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})).toThrow(/repeats one scheduled attempt/)
|
||||
})
|
||||
|
||||
it('starts a fresh retry chain after incomplete predecessor boundaries', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -261,4 +320,16 @@ describe('llm-retry invariants', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('accepts a scheduled and started attempt on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = openStep(ctx, 'retry-invariant-late-started')
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
session.append('llm/retry-started', {
|
||||
retryId: normal.retryId, turn: 1, step: 1, retry: 1,
|
||||
})
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
|
||||
import type {} from '../src/index.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
|
||||
reason: 'initial',
|
||||
})
|
||||
const event = session.append('llm/retry', {
|
||||
retryId: RetryId(`retry-${kind}-chain`),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
|
||||
@@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
const event = await scheduled
|
||||
|
||||
expect(event.data.retryId).toEqual(expect.any(String))
|
||||
expect(event.data).toEqual({
|
||||
retryId: event.data.retryId,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: ddbc2ea482ca0848fb0ee0813839cf5ff1829bcc
|
||||
README.zh.md: 7a3b615d134e27d7c9892d6f411066d89938175b
|
||||
README.md: c74dd5705ab67d5bf4bf966cfb9004e6ae1cc3d7
|
||||
README.zh.md: 17a0fd92b48db3ea5122ac2f1c1ed6e116e6d6e0
|
||||
|
||||
@@ -21,12 +21,12 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize adapter-configured call defaults without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and adapter-default provenance in one exact-model lookup, then capture its current adapter registration and immutable retry policy as one cancellable, one-shot call.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata and markers for fields supplied by adapter defaults in one exact-model lookup, then capture its current adapter registration and immutable retry policy as one cancellable, one-shot call.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy.
|
||||
|
||||
Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`.
|
||||
Interrogating an endpoint is configuration-time work over a *draft*, keyed by settings namespace rather than by provider route — the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route answers from its own knowledge without a network call; `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`.
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
@@ -51,13 +51,13 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
||||
|
||||
`Message` is the shared immutable value used by delivery, durable history, and model requests. Every message has a required `MessageId`, role, content, and typed source from creation onward. `createMessage(input)` mints the identity and returns a detached deep-frozen value; `createUserMessage({ content, source })` fixes the user role; `createAssistantMessage({ content, source })` fixes the assistant role and model source kind; `createToolResultMessage({ callId, content, isError })` fixes the user role and couples the tool source to its result block; `freezeMessage(message)` imports an identity that already exists and never replaces it. Message rewrites preserve the identity and produce another frozen value. Browser code imports these value constructors from the dependency-minimal `@deepseek-ai/dsh-llm/message` entry instead of the service-bearing package root.
|
||||
|
||||
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value plus adapter-default provenance before using the prepared call's registration-bound stream. The next proposal omits marked defaults so a changed route resolves its own values; unmarked explicit fields persist. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `prepareCall()` validates it and materializes adapter defaults under the turn signal, and the loop logs the effective value plus markers for fields supplied by adapter defaults before using the prepared call's registration-bound stream. The next proposal omits marked defaults so a changed route resolves its own values; unmarked explicit fields persist. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` marks that exact object as created by the process-local agent loop, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
@@ -71,7 +71,7 @@ Every adapter that puts a credential in an HTTP header judges it the same way be
|
||||
|
||||
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
|
||||
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and can create an identified, frozen assistant message from them. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks for history.
|
||||
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
|
||||
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at process boundaries.
|
||||
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error.
|
||||
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
|
||||
@@ -93,7 +93,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores retry policy, but `llm/stream` remains a single-attempt call-wrapper seam. The agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure; `@deepseek-ai/dsh-llm-retry` is the optional executor loaded by the shared example spine.
|
||||
- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores retry policy, but `llm/stream` remains a single-attempt call wrapper. The agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure; `@deepseek-ai/dsh-llm-retry` is the optional executor loaded by the shared example spine.
|
||||
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)).
|
||||
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
|
||||
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的调用默认值,但不自动调整。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据与适配器默认值溯源,再将当前适配器注册和不可变重试策略捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置、脱耦的上下文元数据以及标明哪些字段由适配器默认值填入的标记,再将当前适配器注册和不可变重试策略捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始分片(token 级增量)。消费方使用 `BlockAssembler` 将分片组装为块/消息。
|
||||
|
||||
`LlmService` 将最终适配器选择、同步 dispatch、iterator 构造与迭代中的失败规范化为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。
|
||||
|
||||
询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。
|
||||
询问端点属于配置期针对**草稿**的操作,以 settings namespace 而非提供方路由为键——界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器会用自己的知识作答,无需联网;`baseURL` 可选,两者至少要有一个。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。
|
||||
|
||||
提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
@@ -51,13 +51,13 @@
|
||||
|
||||
`Message` 是投递、持久历史和模型请求共享的不可变值。每条消息从创建起都必须具有 `MessageId`、角色、内容和带类型的来源。`createMessage(input)` 生成标识,并返回与输入分离且深度冻结的值;`createUserMessage({ content, source })` 固定 user 角色;`createAssistantMessage({ content, source })` 固定 assistant 角色与模型来源类别;`createToolResultMessage({ callId, content, isError })` 固定 user 角色,并将工具来源与其结果块耦合;`freezeMessage(message)` 导入已有标识,绝不将其替换。改写消息时会保留标识,并产生另一个冻结值。浏览器端代码会从依赖最少的 `@deepseek-ai/dsh-llm/message` 入口导入这些值构造函数,而不是从包含服务的包根入口导入。
|
||||
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。
|
||||
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用其 `error` 或 `aborted` 原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。
|
||||
|
||||
### 调用配置(`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值及适配器默认值来源,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 为该精确对象添加进程本地 loop 溯源,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。
|
||||
`LlmCallConfig` 是一个会话中各次请求的提供方、模型、可选的适配器持有推理强度和采样标量(`provider`、`model`、`reasoningEffort`、`temperature`、`maxTokens`、`stop`,每个都与同名 `GenerateOptions` 字段 1:1 映射)。它是作为请求标头一部分记录在会话日志中的每会话状态(见 dsh-session `request/header` 事件),绝不是可静默调整的每次调用旋钮:`agent/request` waterfall 会提议替换,`prepareCall()` 在轮次 signal 控制下校验它并填入适配器默认值,loop 随后记录生效值以及标明哪些字段由适配器默认值填入的标记,再使用已准备调用中与注册绑定的流。下一次提议会省略带标记的默认值,使变更后的路由解析自身的值;未带标记的显式字段会保留。`callConfigEquals(a, b)` 是逐字段真实变更检测器;`deepFreeze(value)` 是 loop 在 dispatch 前对每个已构建请求应用的所有权 helper(`llm/stream` listener 与适配器只读,绝不改写)。`markAgentLoopRequest()` 将该精确对象标记为由进程本地 agent loop 创建,`isAgentLoopRequest()` 让观测方可以将其与同样可能冻结并关联会话、但独立记录的辅助调用区分。`GenerateOptions.purpose` 对已记录辅助压缩与会话标题调用分类,让适配器可以应用目的特定传输策略,而不改变普通会话请求。
|
||||
|
||||
### 应用归因(`attribution.ts`)
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
- `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。
|
||||
- `BlockAssembler`:将原始分片逐步组装为完整内容块,并能据此创建带标识且冻结的 assistant 消息。agent loop 向它提供原始分片(同时记录以供回放),并读取已组装块以构建历史。
|
||||
- `HarnessError`:harness 错误分类体系的基类,包含稳定 `code` 字符串(与面向人的 `message` 不同)加 `cause` 链接。它位于所有其他包都从中导入的叶子包中,因此可以共享单一基类,无需新的依赖边。各包的错误(`LlmError`、`ToolArgsError`、`InvariantError` 等)都继承自它。`isHarnessError(value)` 在 seam 处收窄类型。
|
||||
- `HarnessError`:harness 错误分类体系的基类,包含稳定 `code` 字符串(与面向人的 `message` 不同)加 `cause` 链接。它位于所有其他包都从中导入的叶子包中,因此可以共享单一基类,无需新的依赖边。各包的错误(`LlmError`、`ToolArgsError`、`InvariantError` 等)都继承自它。`isHarnessError(value)` 在进程边界处收窄类型。
|
||||
- `LlmError`:继承自 `HarnessError`;其稳定 `code` 字符串(`NO_ADAPTER`、`DUPLICATE_ADAPTER` 与 `AUTH`/`RATE_LIMIT` 等适配器 code)与冻结可序列化 `failure.code` 匹配。Payload 还可以保留已验证状态、`Retry-After` 和品牌化提供方请求 id 事实;策略位于错误之外。
|
||||
- `errorChain(value)`:渲染抛出值的完整 `cause` 链与 AggregateError 成员,供诊断表层使用,包括 UI 通知、logger 行和持久 `turn/end` 消息。因此 undici 的 `TypeError: fetch failed` 等传输包装层会显示底层 `ECONNREFUSED`/DNS/TLS 详细信息,而不是将其遮蔽。该函数只负责渲染:请按 `code` 路由,绝不解析结果。
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。
|
||||
@@ -93,9 +93,9 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装 seam。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例主干加载的可选执行器。
|
||||
- **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装层。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例主干加载的可选执行器。
|
||||
- **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。
|
||||
- **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
|
||||
- **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。
|
||||
- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:该公开主页必须在首次发布前可访问。
|
||||
- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:该公开主页必须在发布前可访问。
|
||||
- **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。
|
||||
|
||||
@@ -61,7 +61,7 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
|
||||
/**
|
||||
* Mark one exact request object as assembled by dsh-agent-loop.
|
||||
* @param request - loop-owned request envelope before LLM dispatch.
|
||||
* @returns the same request object with process-local loop provenance.
|
||||
* @returns the same request object marked as created by the process-local agent loop.
|
||||
*/
|
||||
export function markAgentLoopRequest<T extends GenerateOptions>(request: T): T {
|
||||
AGENT_LOOP_REQUESTS.add(request)
|
||||
|
||||
@@ -154,7 +154,7 @@ export function errorChain(value: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
|
||||
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at runtime boundaries).
|
||||
* @param value - the caught value (`unknown` in catch clauses).
|
||||
* @returns true only for real instances; duck-typed or cross-realm errors do not narrow.
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MessageId, type CallId } from './brand.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
import type { ContentBlock, ToolResultBlock } from './types.ts'
|
||||
|
||||
/** Provider ownership and adapter-private replay data for an assistant message. */
|
||||
/** Provider/model identity and adapter-private replay data for an assistant message. */
|
||||
export interface AssistantProvenance {
|
||||
/** Provider route that produced the message. */
|
||||
provider: string
|
||||
@@ -31,12 +31,12 @@ export interface ToolMessageSource {
|
||||
|
||||
/**
|
||||
* What SHAPE of information a producer-supplied context carries, declared by
|
||||
* the producer beside its provenance.
|
||||
* the producer beside the source fields it supplied.
|
||||
*
|
||||
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
|
||||
* of thing it is*, and the two axes are deliberately independent — several
|
||||
* producers share one form (three snapshot producers today), and one producer
|
||||
* may emit more than one form over a session.
|
||||
* producers share one form, and one producer may emit more than one form over
|
||||
* a session.
|
||||
*
|
||||
* The vocabulary is SEMANTIC, never visual: a value states that the content is
|
||||
* a file's instructions or a catalog of available items, and a consumer decides
|
||||
@@ -133,7 +133,7 @@ export interface Message {
|
||||
readonly role: 'system' | 'user' | 'assistant'
|
||||
/** Exact model-facing blocks. */
|
||||
readonly content: ContentBlock[]
|
||||
/** Required producer provenance. */
|
||||
/** Required source fields supplied by the producer. */
|
||||
readonly source: MessageSource
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ export function createUserMessage<T extends NewUserMessage>(
|
||||
|
||||
/**
|
||||
* Create one identified model-produced assistant message and freeze it before publication.
|
||||
* @param input - complete content and model provenance for a new assistant message.
|
||||
* @param input - complete content plus the provider, model, and optional replay state for a new assistant message.
|
||||
* @returns an immutable assistant message with fixed role/source tags and a fresh stable identity.
|
||||
*/
|
||||
export function createAssistantMessage(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Provider-owned request-retry policy configuration and resolution.
|
||||
*
|
||||
* Adapters expose one resolved policy per registered provider route; the
|
||||
* optional dsh-llm-retry plugin executes it on the agent's failed-step seam.
|
||||
* optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/retry-policy
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AppIdentity } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const manifest = createRequire(import.meta.url)('../package.json') as { version: string }
|
||||
|
||||
/** A white-label identity exercising every override seam. */
|
||||
/** A white-label identity exercising every override hook. */
|
||||
const forkIdentity: AppIdentity = {
|
||||
product: 'fork-agent',
|
||||
version: '9.9.9',
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('message construction', () => {
|
||||
expect(Object.isFrozen(message.source)).toBe(true)
|
||||
})
|
||||
|
||||
it('couples tool-result content and provenance to one call identity', () => {
|
||||
it('couples tool-result content and its cited call seq to one call identity', () => {
|
||||
const callId = CallId('call-1')
|
||||
const message = createToolResultMessage({
|
||||
callId,
|
||||
|
||||
@@ -1067,7 +1067,7 @@ describe('LlmService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('strips replay state but preserves provenance when the target uses a different adapter instance', async () => {
|
||||
it('strips replay state but preserves provider and model when the target uses a different adapter instance', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['historical'], new RecordingAdapter(SCRIPT))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
|
||||
README.md: 8f868f25f3c4caf1fdab5b50965aab41efecf5af
|
||||
README.zh.md: 3621105ff35606b62b0587063038116b4772c6cf
|
||||
README.md: 6cbbefa69fd4dd108d4c4ff717647e173922d7b2
|
||||
README.zh.md: 572409324e910f4b38ad48b2ec5fb2c85d4fd088
|
||||
|
||||
@@ -6,7 +6,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I
|
||||
|
||||
## Configuration
|
||||
|
||||
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelInfo().context`.
|
||||
The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelInfo().context`.
|
||||
|
||||
## Measurement contract
|
||||
|
||||
@@ -17,9 +17,9 @@ The estimator has no settings. It intentionally uses one fixed heuristic: four c
|
||||
|
||||
`measure()` synchronizes once and returns one detached, deeply immutable snapshot. `totalTokens` is request-and-response pressure, while `surfaceTokens` is the surface-only heuristic total and equals the sum of `nodes[].tokens`. A `requestHeader` override affects pressure fields only; the surface fields still describe the current session. Every call clones the positional nodes, so measurement is O(surface).
|
||||
|
||||
The fold tracks full request-header snapshots, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and assistant-chunk provenance. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
|
||||
The fold tracks full request-header snapshots, step boundaries, surface appends and replacements, successful assistant messages, provider usage, and the chunk seqs cited by each assistant message. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.
|
||||
|
||||
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
|
||||
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty `sourceEventSeqs` list means a known empty provider stream, while an absent legacy list conservatively treats the durable assistant output as provider output.
|
||||
|
||||
## Session projections
|
||||
|
||||
@@ -41,7 +41,7 @@ The occupancy fields are independent last-wins records and are **not** one atomi
|
||||
|
||||
This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. A UI computes occupancy by dividing measured pressure by the separately resolved capacity for the selected model.
|
||||
|
||||
Making the pair atomic was tried and rejected: it required a transient non-replayable wire frame, which needed lifecycle fencing against cross-stream reordering and left occupancy blank after every reconnect. The [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) records that comparison. Consumers that need an exact same-boundary figure should call `measure()` at their own request boundary rather than read this projection.
|
||||
The [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md) records the rejected atomic-pair comparison. Consumers that need an exact same-boundary figure should call `measure()` at their own request boundary rather than read this projection.
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -65,4 +65,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer.
|
||||
- **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
|
||||
- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.
|
||||
- **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
|
||||
- **Missing legacy source seqs are handled conservatively** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
## 配置
|
||||
|
||||
估算器没有配置项。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何配置键都会被拒绝,包括已废弃的全局 `contextWindow`;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelInfo().context` 获取。
|
||||
估算器没有配置项。它有意使用一项固定启发式规则:每个 token 按四个字符估算,再加上角色、块与请求 envelope 字段的结构开销。任何配置键都会被拒绝;模型容量属于拥有精确提供方/模型路由的适配器,可通过 `ctx.llm.resolveModelInfo().context` 获取。
|
||||
|
||||
## 测量契约
|
||||
## 测量约定
|
||||
|
||||
`ctx.tokenMeter` 直接公开两个操作:
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
|
||||
`measure()` 会同步一次,并返回一个独立且深度不可变的快照。`totalTokens` 是请求与响应压力,`surfaceTokens` 是仅表层启发式总量,等于 `nodes[].tokens` 之和。`requestHeader` 覆盖只影响压力字段;表层字段仍描述当前会话。每次调用都会克隆带位置的节点,因此测量是 O(surface)。
|
||||
|
||||
fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成功 assistant 消息、提供方用量和 assistant 分片溯源。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整启发式锚点时,才会复用提供方用量;后续成功会替换较早锚点。否则会对当前 envelope 与表层进行完整估算。表层变更保持相对于匹配锚点的带符号值,包括缩减替换后的负 delta。
|
||||
fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成功 assistant 消息、提供方用量,以及每条 assistant 消息引用的分片 seq。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整启发式锚点时,才会复用提供方用量;后续成功会替换较早锚点。否则会对当前 envelope 与表层进行完整估算。表层变更保持相对于匹配锚点的带符号值,包括缩减替换后的负 delta。
|
||||
|
||||
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加推理(reasoning)。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式空溯源列表表示已知空提供方流,而遗留溯源缺失时,fold 会保守地将持久 assistant 输出视为提供方输出。
|
||||
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加推理(reasoning)。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式的空 `sourceEventSeqs` 列表表示已知空提供方流;遗留记录缺少该列表时,fold 会保守地将持久 assistant 输出视为提供方输出。
|
||||
|
||||
## 会话投影
|
||||
|
||||
@@ -29,9 +29,9 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
|
||||
|
||||
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。
|
||||
|
||||
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到又跑完一整轮为止。占用率展示读取 `projectedTokens`。
|
||||
`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。
|
||||
|
||||
`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——与 `measure()` 运行的位置折叠是同一份——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点恰好把这些明细行仍然带着的误差排除在外(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
|
||||
`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——也就是 `measure()` 运行的同一个带位置 fold——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。
|
||||
|
||||
三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。
|
||||
|
||||
@@ -41,7 +41,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
|
||||
|
||||
这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。
|
||||
|
||||
让这对值保持原子已经尝试过并被否决:它需要一个临时且不可回放的协议帧,进而需要针对跨流重排序的生命周期栅栏,还会让占用率在每次重连后变为空白。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md)记录了这项对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。
|
||||
[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md)记录了否决「让这对值保持原子」方案的那次对比。需要同一边界精确数字的消费方应在自己的请求边界调用 `measure()`,而不是读取该投影。
|
||||
|
||||
## 组合
|
||||
|
||||
@@ -65,4 +65,4 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
|
||||
- **固定启发式规则是近似值**:没有可复用提供方用量的内容按字符数加结构开销计价,而不是使用精确提供方 tokenizer 或请求 serializer。
|
||||
- **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。
|
||||
- **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。
|
||||
- **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。
|
||||
- **保守处理缺少源事件 seq 的遗留记录**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: resolves the optional projection registry Context seam.
|
||||
// Type-only: resolves the optional projection registry Context declaration.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type {
|
||||
TokenMeasurement,
|
||||
@@ -270,9 +270,9 @@ export class TokenMeterService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble provider output from exact chunk provenance for a usage anchor.
|
||||
* Missing legacy provenance conservatively treats the durable output as the
|
||||
* provider output; explicit empty provenance prices a known empty stream.
|
||||
* Reassemble provider output from the exact cited chunk seqs for a usage anchor.
|
||||
* Missing legacy source seqs conservatively treat the durable output as the
|
||||
* provider output; an explicit empty list prices a known empty stream.
|
||||
*/
|
||||
private _estimateProviderAssistant(
|
||||
session: Session,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compact'
|
||||
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
|
||||
import {
|
||||
estimateContent,
|
||||
@@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
compactionId: CompactionId('context-breakdown-summary'),
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('replay anchors and surface folds', () => {
|
||||
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('distinguishes explicit empty provenance from absent legacy provenance', () => {
|
||||
it('distinguishes an explicit empty source-event list from an absent legacy list', () => {
|
||||
const explicit = Session.create(SessionId('explicit-empty'))
|
||||
const legacy = Session.create(SessionId('legacy-absent'))
|
||||
appendSuccessfulCall(explicit, header('deepseek-v4-flash'), {
|
||||
@@ -503,7 +503,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid assistant provenance', () => {
|
||||
it('rejects invalid assistant source-event references', () => {
|
||||
const cases: Array<{
|
||||
name: string
|
||||
appendSource(session: Session): number[]
|
||||
@@ -553,7 +553,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects repeated and non-earlier assistant provenance', () => {
|
||||
it('rejects repeated and non-earlier assistant source-event references', () => {
|
||||
const duplicate = Session.create(SessionId('duplicate-source'))
|
||||
duplicate.append('step/start', { turn: 1, step: 1 })
|
||||
appendHeader(duplicate, header('deepseek-v4-flash'))
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compact'
|
||||
|
||||
const ZERO: TokenUsageProjection = {
|
||||
uncachedInputTokens: 0,
|
||||
@@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
|
||||
const endIdx = nodes.findIndex(node => node.seq === end)
|
||||
const shadowed = nodes.slice(startIdx, endIdx + 1)
|
||||
session.append('compact/summary', {
|
||||
compactionId: CompactionId('token-usage-summary'),
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: shadowed.map(node => node.seq),
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
Reference in New Issue
Block a user