Merge updated origin/master into feat/tui-master-port
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 13a04aa9f73fec5824069644449009989d6fd924
|
||||
README.zh.md: 3e417c5f8be1f7831b99940c2a4aec815dc2c5b6
|
||||
# pnpm run verify-translation-pairing --write packages/llm/README.md
|
||||
README.md: 66b7beabd73cc3fec7230f209a9da0da48a37c95
|
||||
README.zh.md: 48c54358ce3e8e21e33a6ef5b75a7e095b6581d5
|
||||
|
||||
@@ -8,8 +8,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
|
||||
|---|---|---|
|
||||
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
|
||||
| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` |
|
||||
| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) |
|
||||
| `llm-retry/` | Exact-provider normal or unbounded request retry policy | (listens to `agent/request-error`) |
|
||||
| `llm-deepseek/` | DeepSeek API adapter (direct fetch + eventsource-parser SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) |
|
||||
|
||||
The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership.
|
||||
The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter supplies retry policy and resolves available exact-model identity, context capacity, and reasoning metadata; the retry executor and token meter remain provider-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership.
|
||||
|
||||
@@ -8,8 +8,8 @@ LLM seam 及其提供方适配器。接口包(`llm`)拥有抽象服务、内
|
||||
|---|---|---|
|
||||
| `llm/` | 抽象 LLM 服务 + 内容块词汇 + 分片组装器 | `ctx.llm` |
|
||||
| `token-meter/` | 感知回放的请求与表层 token 测量 | `ctx.tokenMeter` |
|
||||
| `llm-retry/` | 有界的暂时性请求重试策略 | (监听 `agent/request-error`) |
|
||||
| `llm-retry/` | 确切提供方的 normal 或无界请求重试策略 | (监听 `agent/request-error`) |
|
||||
| `llm-deepseek/` | DeepSeek API 适配器(直接 fetch + eventsource-parser SSE) | (注册到 `ctx.llm`) |
|
||||
| `llm-pi-ai/` | 通过 `@earendil-works/pi-ai` 实现的多提供方适配器 | (注册到 `ctx.llm`) |
|
||||
|
||||
接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器可以解析精确的提供方/模型上下文容量;token 计量器仍与模型无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动接口或消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。
|
||||
接口位于 `llm/llm/`;适配器、重试策略和可复用的 token 计量器都是该分组下的扁平兄弟包。请求按 `provider` 路由,而 `model` 会原样传给选中的适配器。拥有路由的适配器提供重试策略,并解析可用的确切模型身份、上下文容量和推理元数据;重试执行器与 token 计量器仍与提供方无关。新的提供方适配器只需在 `ctx.llm` 上注册一个或多个提供方路由,无需改动消费方。两个已交付实现见[双生 LLM 适配器](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),测量归属见[回放 token 计量器 Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md),容量与压缩策略归属见[路由模型上下文 Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md)。
|
||||
|
||||
@@ -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: 4358620295547248ca87c42e07022c5eab0c947b
|
||||
README.zh.md: 4ecc5dd2e5980751ca6e724b5041efefc8114077
|
||||
README.md: a7f2fcb9c21d45a95fc81abd3dc1424d4336966d
|
||||
README.zh.md: bca56e700c0067b644adb4d1460a47901db28209
|
||||
|
||||
@@ -19,6 +19,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
mode: always # normal | always
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
@@ -28,7 +34,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
contextWindow: 64000
|
||||
```
|
||||
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists, `context` is absent without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
@@ -36,7 +42,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; agent-level retry is a separate plugin policy.
|
||||
`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.
|
||||
|
||||
## App attribution
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
retryPolicy: # optional; omission uses bounded normal defaults
|
||||
mode: always # normal | always
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
@@ -28,7 +34,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
contextWindow: 64000
|
||||
```
|
||||
|
||||
该插件注册唯一提供方路由 `deepseek`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
该插件注册唯一提供方路由 `deepseek`,同时注册解析后的 `retryPolicy`。请求使用 `provider: deepseek` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 128,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek')` 公开给 UI selector 与部署自省,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。
|
||||
|
||||
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。两者都不存在时,`context` 字段缺失但不会使路由失效。因此,压力敏感插件可以获得部署拥有的容量,不会将模型 selector 视为权威。为 `deepseek` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
@@ -36,7 +42,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
|
||||
`thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩默认值。
|
||||
|
||||
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;agent 级重试是独立插件策略。
|
||||
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。
|
||||
|
||||
## 应用归因
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
ResolvedRetryPolicy,
|
||||
RetryPolicyConfig,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -46,6 +48,8 @@ export interface DeepSeekAdapterOptions {
|
||||
models?: readonly DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
@@ -115,6 +119,7 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin
|
||||
*/
|
||||
export class DeepSeekAdapter extends LlmAdapter {
|
||||
private readonly streamIdleTimeoutMs: number
|
||||
private readonly retryPolicy: ResolvedRetryPolicy
|
||||
|
||||
constructor(private readonly options: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
@@ -135,12 +140,17 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy')
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: 'DeepSeek' }
|
||||
}
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.retryPolicy
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model)))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel } from './adapter.ts'
|
||||
@@ -47,6 +48,8 @@ export interface Config {
|
||||
models?: DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
@@ -64,6 +67,7 @@ export const Config: z<Config> = z.object({
|
||||
defaultContextWindow: z.number().step(1).min(1),
|
||||
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
||||
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
||||
retryPolicy: RetryPolicySchema,
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
@@ -117,5 +121,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
: { defaultContextWindow: config.defaultContextWindow },
|
||||
models: resolveModels(config.models),
|
||||
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
||||
...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -602,6 +602,26 @@ describe('plugin registration and config', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('registers retryPolicy from the provider config', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0.2,
|
||||
})
|
||||
})
|
||||
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -908,4 +928,16 @@ describe('plugin registration and config', () => {
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
})
|
||||
|
||||
it('rejects invalid nested retryPolicy before registering the provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
})).rejects.toThrow(/retryPolicy/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 24a4762342f1ce8e71d1a5b1733fe02257823cb8
|
||||
README.zh.md: 557dc892c2eac10edc4e2fe4a0a142024942b1a9
|
||||
README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0
|
||||
README.zh.md: a3d864ed9068d8bdaff4c5b73a4b7b339802ae05
|
||||
|
||||
@@ -19,6 +19,13 @@ Configure credentials and deployment-specific transport settings per provider. O
|
||||
apiKey: !!js process.env.OPENAI_API_KEY
|
||||
baseURL: https://proxy.example.com:8443
|
||||
reasoning: high
|
||||
retryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 3
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
- provider: anthropic
|
||||
apiKey: !!js process.env.ANTHROPIC_API_KEY
|
||||
streamIdleTimeoutMs: 300000
|
||||
@@ -34,7 +41,7 @@ The adapter exposes each configured provider's installed pi-ai models through `c
|
||||
|
||||
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
|
||||
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
|
||||
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
|
||||
|
||||
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
|
||||
|
||||
@@ -102,4 +109,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 not an adapter option** — SDK retries are disabled so durable agent steps and `llm/retry` events own every visible attempt; 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 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.
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
apiKey: !!js process.env.OPENAI_API_KEY
|
||||
baseURL: https://proxy.example.com:8443
|
||||
reasoning: high
|
||||
retryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 3
|
||||
backoff:
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
- provider: anthropic
|
||||
apiKey: !!js process.env.ANTHROPIC_API_KEY
|
||||
streamIdleTimeoutMs: 300000
|
||||
@@ -34,7 +41,7 @@
|
||||
|
||||
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型协议拼写仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
|
||||
受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs` 和 `streamIdleTimeoutMs`。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。
|
||||
受支持的 profile 字段是 `provider`、`apiKey`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的 normal 默认值。流 idle 间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。Harness 应用归因会胜过名称冲突的已配置标头。
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent 级重试预算。Idle 过期会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
@@ -102,4 +109,4 @@ pi-ai 事件会变为 harness reasoning、文本、工具调用、usage 与 fini
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。
|
||||
- **重试策略不是适配器选项**:SDK 重试已禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每次可见尝试;直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤 seam 上执行;pi-ai SDK 重试仍保持禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
ReasoningEffortId as ReasoningEffortIdType,
|
||||
ResolvedRetryPolicy,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -44,7 +45,10 @@ export interface PiAiAdapterOptions {
|
||||
* Resolve a catalog model dynamically and apply only the configured endpoint
|
||||
* override, preserving the catalog's API/capability/compatibility metadata.
|
||||
*/
|
||||
function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
|
||||
function resolvePiModel(
|
||||
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
|
||||
modelId: string,
|
||||
): Model<Api> {
|
||||
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
|
||||
if (model === undefined) {
|
||||
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
|
||||
@@ -54,7 +58,7 @@ function resolvePiModel(profile: PiAiProviderProfile, modelId: string): Model<Ap
|
||||
|
||||
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
||||
function profileOptions(
|
||||
profile: PiAiProviderProfile,
|
||||
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
|
||||
reasoning: ModelThinkingLevel | undefined,
|
||||
): SimpleStreamOptions {
|
||||
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
|
||||
@@ -107,6 +111,10 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
|
||||
}
|
||||
|
||||
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
|
||||
return this.profiles.get(provider)?.retryPolicy
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
const profile = this.profiles.get(provider)
|
||||
if (profile === undefined) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
|
||||
import z from 'schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
@@ -36,12 +38,16 @@ export interface PiAiProviderProfile {
|
||||
websocketConnectTimeoutMs?: number
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
/** Validated profile with every adapter-owned default resolved. */
|
||||
export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile {
|
||||
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'retryPolicy'> {
|
||||
/** Positive finite provider-idle interval after defaulting. */
|
||||
streamIdleTimeoutMs: number
|
||||
/** Immutable retry policy captured with this provider route. */
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Plugin configuration: the non-empty provider profiles this instance owns. */
|
||||
@@ -69,6 +75,7 @@ const profile = z.object({
|
||||
timeoutMs: z.natural(),
|
||||
websocketConnectTimeoutMs: z.natural(),
|
||||
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
||||
retryPolicy: RetryPolicySchema,
|
||||
})
|
||||
|
||||
/** Runtime schema for {@link Config}. */
|
||||
@@ -115,6 +122,10 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): Resol
|
||||
return {
|
||||
...source,
|
||||
streamIdleTimeoutMs,
|
||||
retryPolicy: resolveRetryPolicy(
|
||||
source.retryPolicy,
|
||||
`llm-pi-ai: provider "${source.provider}" retryPolicy`,
|
||||
),
|
||||
...source.headers === undefined ? {} : { headers: { ...source.headers } },
|
||||
...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } },
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* providers:
|
||||
* - provider: openai
|
||||
* apiKey: !!js process.env.OPENAI_API_KEY
|
||||
* retryPolicy:
|
||||
* mode: normal
|
||||
* maxRetries: 2
|
||||
* - provider: anthropic
|
||||
* apiKey: !!js process.env.ANTHROPIC_API_KEY
|
||||
* - provider: openrouter
|
||||
@@ -36,6 +39,6 @@ export const inject = ['llm']
|
||||
/** Register one generic pi-ai adapter for all configured provider routes. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const profiles = resolveProfiles(config.providers)
|
||||
const adapter = new PiAiAdapter({ profiles })
|
||||
const adapter = new PiAiAdapter({ profiles: config.providers })
|
||||
ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter)
|
||||
}
|
||||
|
||||
@@ -330,12 +330,31 @@ describe('provider profile lifecycle', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai' }, { provider: 'anthropic' }],
|
||||
providers: [
|
||||
{
|
||||
provider: 'openai',
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
|
||||
},
|
||||
},
|
||||
{ provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([
|
||||
{ id: 'openai', name: 'openai' },
|
||||
{ id: 'anthropic', name: 'anthropic' },
|
||||
])
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0.2,
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
@@ -460,6 +479,23 @@ describe('provider profile lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
|
||||
expect(() => resolveProfiles([{
|
||||
provider: 'openai',
|
||||
retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } },
|
||||
}])).toThrow(/retryPolicy\.backoff\.jitterRatio/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmPiAi, {
|
||||
providers: [{
|
||||
provider: 'openai',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
}],
|
||||
})).rejects.toThrow(/retryPolicy/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('constructs the adapter directly and rejects routes it does not own', async () => {
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
|
||||
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
|
||||
@@ -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: 596a46e5395a4b5be9d400a85ee7c54d613ec2e6
|
||||
README.zh.md: a6a48f203e4701688816d4365b03da1ae2cfad82
|
||||
README.md: 7a86652a794e70c4dfd00ab7427730387e3ec949
|
||||
README.zh.md: 6255cca8c3b669ddec401496acb1e79ad54a8b3e
|
||||
|
||||
@@ -2,42 +2,52 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Function plugin that retries selected transient model-request failures through the `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn.
|
||||
Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn.
|
||||
|
||||
The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead.
|
||||
Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence.
|
||||
|
||||
The recovery listener appends a non-surface `llm/retry` event after the failed step, waits for the backoff while the failed turn's signal remains live, then returns `{ kind: 'retry' }`. The loop closes that failed turn and opens a retry turn over the same durable history. The policy keeps its own retry count across that uninterrupted recovery chain and clears it at terminal `agent/settled`. Turn cancellation and plugin disposal abort the wait.
|
||||
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.
|
||||
|
||||
The separately published `./invariant` companion checks that every retry record appears inside an open turn after its failed step, matches its position in the current retry chain, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
|
||||
Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. 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.
|
||||
|
||||
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.
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-retry'
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
maxTransientRetries: 2
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
retryPolicy:
|
||||
mode: always
|
||||
backoff:
|
||||
initialDelayMs: 1000
|
||||
maxDelayMs: 30000
|
||||
jitterRatio: 0.2
|
||||
|
||||
- name: '@deepseek-ai/dsh-llm-retry'
|
||||
```
|
||||
|
||||
The executor has no policy config. Multi-provider adapters such as `dsh-llm-pi-ai` place `retryPolicy` inside each provider profile, avoiding a second provider-name list.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Transient request recovery
|
||||
### Model-request recovery
|
||||
|
||||
#### What the model sees
|
||||
|
||||
No retry event, delay, or failure prose is model-visible. The retry turn reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages.
|
||||
No retry event, delay, provider error, or failed partial output is model-visible. The retry turn reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens.
|
||||
Each retry is a new provider request and may repeat input-token billing. Normal mode has a finite budget; always mode can consume unbounded requests until success or cancellation. `llm/retry` itself contributes no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity.
|
||||
The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface retry event does not change cache identity.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Agent turns are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
|
||||
- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior.
|
||||
- **`llm/retry` records completed backoff, not request completion** — later step and turn events establish success, exhaustion, or cancellation.
|
||||
- **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls.
|
||||
- **Finite plugin budgets add** — normal mode counts only its configured codes and exact provider policy, while context-overflow compaction owns a separate budget. A future overlapping policy must document and test registration-order behavior.
|
||||
- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing.
|
||||
- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation.
|
||||
|
||||
@@ -2,42 +2,52 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
一个函数插件,通过 `agent/request-error` waterfall 重试特定的短暂模型请求失败。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。
|
||||
一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。
|
||||
|
||||
默认策略允许为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,使用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对退化提供方完成的分类(携带零个内容块的终止 stop);该尝试未产生持久内容,因此可安全重复。延迟边界必须适合 Node 支持的定时器范围。有效 `providerRetryAfterMs` 在已配置上限内时替换本地退避;超出上限的指令会委托给下一项恢复策略。
|
||||
每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试两次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、合格 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。
|
||||
|
||||
恢复 listener 会在失败步骤之后追加一个非表层 `llm/retry` 事件,在失败轮次的信号仍存活期间等待退避,然后返回 `{ kind: 'retry' }`。循环会关闭该失败轮次,并在同一持久历史上开启重试轮次。策略在这条不间断的恢复链中维护自己的重试计数,并在终态 `agent/settled` 时清零。轮次取消与插件 dispose 会中止等待。
|
||||
两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
|
||||
|
||||
单独发布的 `./invariant` 配套模块会检查每个重试记录是否出现在开启轮次内的失败步骤之后,是否与其在当前重试链中的位置匹配,以及是否携带正数有界重试预算和非负有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。
|
||||
等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、规范的解析策略 key、失败和计划延迟。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。
|
||||
|
||||
单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略身份,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-retry'
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
maxTransientRetries: 2
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
retryPolicy:
|
||||
mode: always
|
||||
backoff:
|
||||
initialDelayMs: 1000
|
||||
maxDelayMs: 30000
|
||||
jitterRatio: 0.2
|
||||
|
||||
- name: '@deepseek-ai/dsh-llm-retry'
|
||||
```
|
||||
|
||||
执行器没有策略配置。`dsh-llm-pi-ai` 等多提供方适配器会把 `retryPolicy` 放在每个提供方 profile 内,避免维护第二份提供方名称列表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 短暂请求恢复
|
||||
### 模型请求恢复
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型不会看到重试事件、延迟或失败文本。重试轮次会从持久会话历史中重建相同的显式提供方/模型请求;失败 chunk 绝不会进入派生消息。
|
||||
模型不会看到重试事件、延迟、提供方错误或失败的部分输出。重试轮次会从持久表层历史中重建相同的显式提供方/模型请求,除非下游恢复策略有意更改该表层;失败分片绝不会进入派生消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每次重试都是新的提供方请求,可能重复计费输入 token。有限预算会限制尝试次数;`llm/retry` 自身不产生 token。
|
||||
每次重试都是新的提供方请求,可能重复计费输入 token。normal mode 具有有限预算;always mode 可以在成功或取消前消耗无界数量的请求。`llm/retry` 自身不产生 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层状态事件不会改变 cache 身份。
|
||||
重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层重试事件不会改变 cache 身份。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Agent 轮次是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法将已发出 chunk 持久分隔为不同尝试。
|
||||
- **有限插件预算可叠加**:该策略只统计已配置短暂 code;上下文溢出压缩只统计自身 code。未来如有 code 重叠的策略,必须记录并测试注册顺序行为。
|
||||
- **`llm/retry` 记录已完成的退避,不是请求完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。
|
||||
- **always mode 会重试永久性失败**:身份验证、配额、无效请求、协议和无法恢复的上下文错误都会继续重试,直至成功、取消或 dispose;部署负责提供方特定的成本与延迟控制。
|
||||
- **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩则拥有独立预算。未来如有重叠策略,必须记录并测试注册顺序行为。
|
||||
- **恢复策略按 waterfall 顺序组合**:always mode 会先接受下游重试,再应用自己的回退。后续策略如果忽略取消且永不结算,也会阻止回退、轮次完全停稳和插件 dispose 完成。
|
||||
- **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-llm-retry",
|
||||
"description": "Bounded transient LLM request retry policy for the DeepSeek Harness",
|
||||
"description": "Provider-routed LLM request retry policy for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
32
packages/llm/llm-retry/src/history.ts
Normal file
32
packages/llm/llm-retry/src/history.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Durable request-route lookup for one closed model step. @module @deepseek-ai/dsh-llm-retry/history */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Find the provider in force when one step closed, excluding later recovery mutations.
|
||||
* Request headers remain effective across turn boundaries until a newer full
|
||||
* snapshot changes them; every provider change requires a newer full snapshot.
|
||||
* @param events - session events containing the closed step.
|
||||
* @param turn - turn that owns the failed step.
|
||||
* @param step - failed step whose provider is required.
|
||||
* @returns the provider from the request header in force at that step boundary.
|
||||
*/
|
||||
export function providerForClosedStep(
|
||||
events: readonly SessionEvent[],
|
||||
turn: number,
|
||||
step: number,
|
||||
): string | undefined {
|
||||
const stepEndIndex = events.findLastIndex(event =>
|
||||
event.type === 'step/end'
|
||||
&& event.data.turn === turn
|
||||
&& event.data.step === step,
|
||||
)
|
||||
if (stepEndIndex < 0) return undefined
|
||||
for (let index = stepEndIndex; index >= 0; index -= 1) {
|
||||
// The loop bounds prove this indexed read exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = events[index]!
|
||||
if (event.type === 'request/header') return event.data.header.config.provider
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Bounded transient model-request retry policy on the agent request-recovery
|
||||
* seam. Each scheduled retry is durable before its cancellable wait.
|
||||
* Provider-routed model-request retry policy on the agent loop's closed-step
|
||||
* recovery seam. Each scheduled retry is durable before its cancellable wait.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-retry
|
||||
*/
|
||||
@@ -8,20 +8,32 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmFailure } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { providerForClosedStep } from './history.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */
|
||||
/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */
|
||||
'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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,82 +41,19 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
export const name = 'llm-retry'
|
||||
export const inject = ['agents']
|
||||
|
||||
const DEFAULT_MAX_TRANSIENT_RETRIES = 2
|
||||
const DEFAULT_INITIAL_DELAY_MS = 500
|
||||
const DEFAULT_MAX_DELAY_MS = 10_000
|
||||
const DEFAULT_JITTER_RATIO = 0.1
|
||||
const DEFAULT_RETRYABLE_CODES = Object.freeze(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
|
||||
|
||||
/** Deployment-owned limits and classification for transient request recovery. */
|
||||
export interface Config {
|
||||
/** Maximum transient retries after the first request (default 2). */
|
||||
maxTransientRetries?: number
|
||||
/** Initial local exponential-backoff delay in milliseconds (default 500). */
|
||||
initialDelayMs?: number
|
||||
/** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */
|
||||
maxDelayMs?: number
|
||||
/** Symmetric random multiplier range around one (default 0.1). */
|
||||
jitterRatio?: number
|
||||
/** Stable failure codes eligible for this policy. */
|
||||
retryableCodes?: string[]
|
||||
}
|
||||
/** This policy executor has no config; providers own `retryPolicy`. */
|
||||
export type Config = Readonly<Record<string, never>>
|
||||
|
||||
/** Runtime schema for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES),
|
||||
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
||||
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
||||
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
|
||||
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
||||
})
|
||||
export const Config = z.object({}) as unknown as z<Config>
|
||||
|
||||
interface ResolvedConfig {
|
||||
readonly maxTransientRetries: number
|
||||
readonly initialDelayMs: number
|
||||
readonly maxDelayMs: number
|
||||
readonly jitterRatio: number
|
||||
readonly retryableCodes: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES
|
||||
const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS
|
||||
const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
||||
const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO
|
||||
const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES]
|
||||
|
||||
if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) {
|
||||
throw new Error('llm-retry: maxTransientRetries must be a non-negative integer')
|
||||
function validateConfig(config: Config): void {
|
||||
const [key] = Object.keys(config)
|
||||
if (key === undefined) return
|
||||
if (key === 'retryPolicy') {
|
||||
throw new Error('llm-retry: retryPolicy belongs under each provider configuration')
|
||||
}
|
||||
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (initialDelayMs > maxDelayMs) {
|
||||
throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs')
|
||||
}
|
||||
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
|
||||
throw new Error('llm-retry: jitterRatio must be between 0 and 1')
|
||||
}
|
||||
if (codes.length === 0) {
|
||||
throw new Error('llm-retry: retryableCodes must not be empty')
|
||||
}
|
||||
if (codes.some(code => code.length === 0)) {
|
||||
throw new Error('llm-retry: retryableCodes must contain only non-empty strings')
|
||||
}
|
||||
if (new Set(codes).size !== codes.length) {
|
||||
throw new Error('llm-retry: retryableCodes must not contain duplicates')
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
maxTransientRetries,
|
||||
initialDelayMs,
|
||||
maxDelayMs,
|
||||
jitterRatio,
|
||||
retryableCodes: new Set(codes),
|
||||
})
|
||||
throw new Error(`llm-retry: unknown key "${key}"`)
|
||||
}
|
||||
|
||||
/** Non-serializable seams used to make timing policy deterministic in tests. */
|
||||
@@ -113,13 +62,40 @@ export interface RetryInternals {
|
||||
random?: () => number
|
||||
}
|
||||
|
||||
function localDelay(config: ResolvedConfig, retry: number, random: () => number): number {
|
||||
type DownstreamOutcome =
|
||||
| { readonly type: 'decision'; readonly decision: RequestErrorAction }
|
||||
| { readonly type: 'error'; readonly error: unknown }
|
||||
|
||||
async function settleDownstream(
|
||||
next: () => Promise<RequestErrorAction>,
|
||||
): Promise<DownstreamOutcome> {
|
||||
try {
|
||||
return { type: 'decision', decision: await next() }
|
||||
} catch (error: unknown) {
|
||||
return { type: 'error', error }
|
||||
}
|
||||
}
|
||||
|
||||
function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number {
|
||||
const exponent = Math.min(retry - 1, 1024)
|
||||
const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs)
|
||||
const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random()
|
||||
return Math.min(exponential * jitter, config.maxDelayMs)
|
||||
}
|
||||
|
||||
function retryPolicyKey(policy: ResolvedRetryPolicy): string {
|
||||
return policy.mode === 'always'
|
||||
? JSON.stringify([policy.mode, policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio])
|
||||
: JSON.stringify([
|
||||
policy.mode,
|
||||
policy.maxRetries,
|
||||
[...policy.retryableCodes].sort(),
|
||||
policy.initialDelayMs,
|
||||
policy.maxDelayMs,
|
||||
policy.jitterRatio,
|
||||
])
|
||||
}
|
||||
|
||||
function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
@@ -136,60 +112,141 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Install bounded transient request recovery.
|
||||
* Install provider-routed normal or unbounded request recovery.
|
||||
* @param ctx - plugin context that owns the listener and active waits.
|
||||
* @param config - retry budget, delay bounds, jitter, and eligible codes.
|
||||
* @param config - empty executor config; provider registrations own policy.
|
||||
* @param internals - non-serializable deterministic seams for tests.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
|
||||
const resolved = resolveConfig(config)
|
||||
validateConfig(config)
|
||||
const random = internals.random ?? Math.random
|
||||
const lifetime = new AbortController()
|
||||
const active = new Set<Promise<RequestErrorAction>>()
|
||||
const retries = new WeakMap<Agent, number>()
|
||||
|
||||
function track(operation: Promise<RequestErrorAction>): Promise<RequestErrorAction> {
|
||||
const tracked = operation.finally(() => active.delete(tracked))
|
||||
active.add(tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
async function backoff(
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
failure: LlmFailure,
|
||||
provider: string,
|
||||
policy: ResolvedRetryPolicy,
|
||||
policyKey: string,
|
||||
retry: number,
|
||||
delayMs: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<RequestErrorAction> {
|
||||
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
||||
if (fusedSignal.aborted) return
|
||||
agent.session.append('llm/retry', {
|
||||
turn,
|
||||
step,
|
||||
retry,
|
||||
maxRetries: resolved.maxTransientRetries,
|
||||
delayMs,
|
||||
failure,
|
||||
})
|
||||
retries.set(agent, retry)
|
||||
const eventData = policy.mode === 'normal'
|
||||
? {
|
||||
turn,
|
||||
step,
|
||||
provider,
|
||||
mode: policy.mode,
|
||||
policyKey,
|
||||
retry,
|
||||
maxRetries: policy.maxRetries,
|
||||
delayMs,
|
||||
failure,
|
||||
}
|
||||
: {
|
||||
turn,
|
||||
step,
|
||||
provider,
|
||||
mode: policy.mode,
|
||||
policyKey,
|
||||
retry,
|
||||
delayMs,
|
||||
failure,
|
||||
}
|
||||
agent.session.append('llm/retry', eventData)
|
||||
if (!await cancellableDelay(delayMs, fusedSignal)) return
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
|
||||
ctx.on('agent/settled', (agent) => {
|
||||
retries.delete(agent)
|
||||
})
|
||||
|
||||
// A completed model response ends the consecutive-failure sequence even
|
||||
// when its tool calls keep the turn running into another request.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'assistant/message') return
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent?.session === session) retries.delete(agent)
|
||||
})
|
||||
|
||||
const disposeListener = ctx.on('agent/request-error', (
|
||||
async function recover(
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
_error: RequestError,
|
||||
failure: LlmFailure,
|
||||
priorFailures: readonly LlmFailure[],
|
||||
policy: ResolvedRetryPolicy | undefined,
|
||||
signal: AbortSignal,
|
||||
next: () => Promise<RequestErrorAction>,
|
||||
): Promise<RequestErrorAction> {
|
||||
if (policy === undefined) return next()
|
||||
// The call-local policy belongs to the registration that served this
|
||||
// failure. Recover only the durable provider identity from the header;
|
||||
// downstream recovery may append later state before an always fallback.
|
||||
const provider = providerForClosedStep(agent.session.events, turn, step)
|
||||
/* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */
|
||||
if (provider === undefined) {
|
||||
throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`)
|
||||
}
|
||||
if (policy.mode === 'always') {
|
||||
if (signal.aborted || lifetime.signal.aborted) return
|
||||
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
||||
// The loop and plugin lifetime stay open until delegated recovery settles.
|
||||
// An abort then wins before the decision or fallback can mutate later state.
|
||||
const downstream = await settleDownstream(next)
|
||||
if (fusedSignal.aborted) return
|
||||
if (downstream.type === 'error') {
|
||||
ctx.logger.warn(
|
||||
`llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`,
|
||||
downstream.error,
|
||||
)
|
||||
}
|
||||
if (downstream.type === 'decision' && downstream.decision?.kind === 'retry') {
|
||||
return downstream.decision
|
||||
}
|
||||
} else if (!policy.retryableCodes.includes(failure.code)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const policyKey = retryPolicyKey(policy)
|
||||
const firstPriorTurn = turn - priorFailures.length
|
||||
const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
|
||||
event.type === 'llm/retry'
|
||||
&& event.data.turn >= firstPriorTurn
|
||||
&& event.data.turn < turn
|
||||
&& event.data.provider === provider
|
||||
&& event.data.policyKey === policyKey,
|
||||
)
|
||||
const previousRetry = priorPolicyRetry?.data.retry ?? 0
|
||||
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
|
||||
const retry = previousRetry + 1
|
||||
let delayMs: number
|
||||
if (failure.providerRetryAfterMs !== undefined
|
||||
&& Number.isFinite(failure.providerRetryAfterMs)
|
||||
&& failure.providerRetryAfterMs > 0) {
|
||||
if (failure.providerRetryAfterMs > policy.maxDelayMs) {
|
||||
if (policy.mode === 'normal') return next()
|
||||
delayMs = localDelay(policy, retry, random)
|
||||
} else {
|
||||
delayMs = failure.providerRetryAfterMs
|
||||
}
|
||||
} else {
|
||||
delayMs = localDelay(policy, retry, random)
|
||||
}
|
||||
|
||||
return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal)
|
||||
}
|
||||
|
||||
const disposeListener = ctx.on('agent/request-error', (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
error: RequestError,
|
||||
failure: LlmFailure,
|
||||
priorFailures: readonly LlmFailure[],
|
||||
policy: ResolvedRetryPolicy | undefined,
|
||||
signal: AbortSignal,
|
||||
next: () => Promise<RequestErrorAction>,
|
||||
) => {
|
||||
@@ -197,30 +254,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
// removed. Lifetime cancellation must prevent that stale callback from
|
||||
// entering a downstream policy after disposal.
|
||||
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
|
||||
if (!resolved.retryableCodes.has(failure.code)) return next()
|
||||
const priorRetries = retries.get(agent) ?? 0
|
||||
if (priorRetries >= resolved.maxTransientRetries) return next()
|
||||
|
||||
const retry = priorRetries + 1
|
||||
let delayMs: number
|
||||
if (failure.providerRetryAfterMs !== undefined
|
||||
&& Number.isFinite(failure.providerRetryAfterMs)
|
||||
&& failure.providerRetryAfterMs > 0) {
|
||||
if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next()
|
||||
delayMs = failure.providerRetryAfterMs
|
||||
} else {
|
||||
delayMs = localDelay(resolved, retry, random)
|
||||
}
|
||||
|
||||
const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal)
|
||||
.finally(() => active.delete(tracked))
|
||||
active.add(tracked)
|
||||
return tracked
|
||||
return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next))
|
||||
})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
disposeListener()
|
||||
lifetime.abort(new Error('llm-retry plugin disposed'))
|
||||
await Promise.allSettled([...active])
|
||||
}, 'llm-retry: abort and drain backoffs')
|
||||
}, 'llm-retry: abort and drain active recovery')
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { LlmFailure } from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { providerForClosedStep } from './history.ts'
|
||||
import type {} from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry'
|
||||
@@ -13,6 +15,32 @@ export const name = 'llm-retry-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the complete provider-neutral failure payload at the durable boundary. */
|
||||
function validateFailure(value: unknown, fail: InvariantFailure): asserts value is LlmFailure {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
fail('llm/retry failure must be an object')
|
||||
}
|
||||
const failure = value as Partial<LlmFailure>
|
||||
if (typeof failure.message !== 'string' || failure.message.length === 0) {
|
||||
fail('llm/retry failure.message must be a non-empty string')
|
||||
}
|
||||
if (typeof failure.code !== 'string' || failure.code.length === 0) {
|
||||
fail('llm/retry failure.code must be a non-empty string')
|
||||
}
|
||||
if (failure.status !== undefined
|
||||
&& (!Number.isInteger(failure.status) || failure.status < 100 || failure.status > 599)) {
|
||||
fail('llm/retry failure.status must be an integer from 100 through 599 when present')
|
||||
}
|
||||
if (failure.providerRetryAfterMs !== undefined
|
||||
&& (!Number.isFinite(failure.providerRetryAfterMs) || failure.providerRetryAfterMs <= 0)) {
|
||||
fail('llm/retry failure.providerRetryAfterMs must be a positive finite number when present')
|
||||
}
|
||||
if (failure.requestId !== undefined
|
||||
&& (typeof failure.requestId !== 'string' || failure.requestId.length === 0)) {
|
||||
fail('llm/retry failure.requestId must be a non-empty string when present')
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first turn in the structured-failure retry chain containing `turn`. */
|
||||
function retryChainStart(history: readonly SessionEvent[], turn: number): number {
|
||||
let startIndex = history.findLastIndex(
|
||||
@@ -47,15 +75,35 @@ function validateRetry(
|
||||
event: SessionEvent<'llm/retry'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
const { turn, step, retry, maxRetries, delayMs } = event.data
|
||||
const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data
|
||||
const failure: unknown = event.data.failure
|
||||
validateFailure(failure, fail)
|
||||
if (!Number.isSafeInteger(retry) || retry < 1) {
|
||||
fail('llm/retry retry must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
|
||||
fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`)
|
||||
if (typeof provider !== 'string' || provider.length === 0) {
|
||||
fail('llm/retry provider must be a non-empty string')
|
||||
}
|
||||
if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) {
|
||||
fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`)
|
||||
if (typeof policyKey !== 'string' || policyKey.length === 0) {
|
||||
fail('llm/retry policyKey must be a non-empty string')
|
||||
}
|
||||
switch (mode) {
|
||||
case 'normal': {
|
||||
const { maxRetries } = event.data
|
||||
if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
|
||||
fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'always':
|
||||
if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries')
|
||||
break
|
||||
default:
|
||||
fail(`llm/retry mode must be normal or always, got ${String(mode)}`)
|
||||
}
|
||||
if (typeof delayMs !== 'number' || !Number.isFinite(delayMs)
|
||||
|| delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) {
|
||||
fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
|
||||
const currentTurnEvents: SessionEvent[] = []
|
||||
@@ -86,6 +134,10 @@ function validateRetry(
|
||||
if (closedStep === undefined || step !== closedStep) {
|
||||
fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`)
|
||||
}
|
||||
const routedProvider = providerForClosedStep(history, turn, step)
|
||||
if (routedProvider !== provider) {
|
||||
fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`)
|
||||
}
|
||||
|
||||
const chainStart = retryChainStart(history, turn)
|
||||
const chain = history.slice(Math.max(chainStart, 0))
|
||||
@@ -95,9 +147,11 @@ function validateRetry(
|
||||
if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) {
|
||||
fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`)
|
||||
}
|
||||
const expectedRetry = chainRetries.length + 1
|
||||
const priorPolicyRetry = chainRetries.findLast(prior =>
|
||||
prior.data.provider === provider && prior.data.policyKey === policyKey)
|
||||
const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1
|
||||
if (retry !== expectedRetry) {
|
||||
fail(`llm/retry retry ${retry} must equal retry-chain position ${expectedRetry}`)
|
||||
fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { 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 { providerForClosedStep } from '../src/history.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
@@ -13,207 +15,272 @@ async function setup(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
|
||||
|
||||
function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: turn === 1
|
||||
? { kind: 'message', source: { kind: 'user' } }
|
||||
: { kind: 'retry' },
|
||||
})
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn, step })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('step/end', { turn, step })
|
||||
return session
|
||||
}
|
||||
|
||||
function appendRetryTurn(session: Session, turn: number) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'retry' } })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('llm/retry', { turn, step: 1, ...normal })
|
||||
}
|
||||
|
||||
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
|
||||
const normal = {
|
||||
provider: 'mock',
|
||||
mode: 'normal' as const,
|
||||
policyKey: 'normal-policy',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 1,
|
||||
failure,
|
||||
}
|
||||
const always = {
|
||||
provider: 'mock',
|
||||
mode: 'always' as const,
|
||||
policyKey: 'always-policy',
|
||||
retry: 1,
|
||||
delayMs: 1,
|
||||
failure,
|
||||
}
|
||||
|
||||
describe('llm-retry invariants', () => {
|
||||
it('accepts increasing retry schedules for successive failed turns', async () => {
|
||||
it('has no provider without the requested closed step or a route marker', () => {
|
||||
expect(providerForClosedStep([], 1, 1)).toBeUndefined()
|
||||
expect(providerForClosedStep([{
|
||||
type: 'step/end',
|
||||
data: { turn: 1, step: 1 },
|
||||
}] as never, 1, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts bounded and unbounded records after successive closed steps', async () => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, 'retry-invariant-valid')
|
||||
|
||||
expect(() => {
|
||||
session.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure,
|
||||
})
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('llm/retry', {
|
||||
turn: 2, step: 1, retry: 2, maxRetries: 2, delayMs: 0, failure,
|
||||
turn: 2, step: 1, ...normal, retry: 2, delayMs: 0,
|
||||
})
|
||||
const unbounded = closeStep(ctx, 'retry-invariant-always')
|
||||
unbounded.append('llm/retry', { turn: 1, step: 1, ...always })
|
||||
}).not.toThrow()
|
||||
expect(() => { ctx.emit('tools/change') }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/],
|
||||
[{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/],
|
||||
[{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/],
|
||||
[{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/],
|
||||
[{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/],
|
||||
[{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/],
|
||||
[{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/],
|
||||
])('rejects invalid retry bounds %#', async (data, message) => {
|
||||
it('validates the complete durable failure payload', async () => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`)
|
||||
const complete = closeStep(ctx, 'retry-invariant-complete-failure')
|
||||
expect(() => {
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...data, failure })
|
||||
complete.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
...always,
|
||||
failure: {
|
||||
message: 'provider busy',
|
||||
code: 'RATE_LIMIT',
|
||||
status: 429,
|
||||
providerRetryAfterMs: 25,
|
||||
requestId: ProviderRequestId('request-1'),
|
||||
},
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const invalidFailures: readonly [string, unknown, RegExp][] = [
|
||||
['null', null, /failure must be an object/],
|
||||
['message-type', { message: 1, code: 'RATE_LIMIT' }, /failure\.message/],
|
||||
['message-empty', { message: '', code: 'RATE_LIMIT' }, /failure\.message/],
|
||||
['code-type', { message: 'failed', code: 1 }, /failure\.code/],
|
||||
['code-empty', { message: 'failed', code: '' }, /failure\.code/],
|
||||
['status-type', { message: 'failed', code: 'RATE_LIMIT', status: 429.5 }, /failure\.status/],
|
||||
['status-low', { message: 'failed', code: 'RATE_LIMIT', status: 99 }, /failure\.status/],
|
||||
['status-high', { message: 'failed', code: 'RATE_LIMIT', status: 600 }, /failure\.status/],
|
||||
[
|
||||
'retry-after-type',
|
||||
{ message: 'failed', code: 'RATE_LIMIT', providerRetryAfterMs: '25' },
|
||||
/failure\.providerRetryAfterMs/,
|
||||
],
|
||||
[
|
||||
'retry-after-zero',
|
||||
{ message: 'failed', code: 'RATE_LIMIT', providerRetryAfterMs: 0 },
|
||||
/failure\.providerRetryAfterMs/,
|
||||
],
|
||||
['request-id-type', { message: 'failed', code: 'RATE_LIMIT', requestId: 1 }, /failure\.requestId/],
|
||||
['request-id-empty', { message: 'failed', code: 'RATE_LIMIT', requestId: '' }, /failure\.requestId/],
|
||||
]
|
||||
for (const [name, invalidFailure, message] of invalidFailures) {
|
||||
const session = closeStep(ctx, `retry-invariant-failure-${name}`)
|
||||
expect(() => {
|
||||
session.append('llm/retry', {
|
||||
turn: 1, step: 1, ...always, failure: invalidFailure,
|
||||
} as never)
|
||||
}).toThrow(message)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['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/],
|
||||
['max-fraction', { ...normal, maxRetries: 1.5 }, /positive safe maxRetries/],
|
||||
['over-budget', { ...normal, retry: 3 }, /must not exceed/],
|
||||
['always-maximum', { ...always, maxRetries: 2 }, /always mode must omit maxRetries/],
|
||||
['unknown-mode', { ...always, mode: 'sometimes' }, /mode must be normal or always/],
|
||||
['empty-provider', { ...always, provider: '' }, /provider must be a non-empty string/],
|
||||
['empty-policy-key', { ...always, policyKey: '' }, /policyKey must be a non-empty string/],
|
||||
['delay-negative', { ...normal, delayMs: -1 }, /delayMs/],
|
||||
['delay-overflow', { ...normal, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/],
|
||||
['delay-type', { ...normal, delayMs: '1' }, /delayMs/],
|
||||
])('rejects invalid retry data: %s', async (name, data, message) => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, `retry-invariant-${name}`)
|
||||
expect(() => {
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...data } as never)
|
||||
}).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a retry record appended after its turn already closed', async () => {
|
||||
const ctx = await setup()
|
||||
const closed = closeStep(ctx, 'retry-invariant-closed-turn')
|
||||
closed.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
expect(() => {
|
||||
closed.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
}).toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('starts a fresh chain when the turn before a retry trigger did not fail structurally', async () => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, 'retry-invariant-completed-predecessor')
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
expect(() => {
|
||||
session.append('llm/retry', {
|
||||
turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('walks the chain across non-boundary events and stops at an unmatched turn start', async () => {
|
||||
const ctx = await setup()
|
||||
// The failed predecessor's turn/start is outside this log prefix (e.g. a
|
||||
// truncated replay): the chain walk must stop rather than loop or throw.
|
||||
const session = ctx.sessions.create(SessionId('retry-invariant-unmatched-start'))
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
// A durable non-boundary record between the turns exercises the walk over
|
||||
// non-turn/end events.
|
||||
session.append('todo/write', { todos: [] })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
expect(() => {
|
||||
session.append('llm/retry', {
|
||||
turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('requires an open turn and its latest closed step', async () => {
|
||||
it('rejects records outside the latest closed step of an open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn'))
|
||||
expect(() => {
|
||||
absent.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
absent.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/inside an open turn/)
|
||||
|
||||
const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn')
|
||||
expect(() => {
|
||||
wrongTurn.append('llm/retry', {
|
||||
turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
wrongTurn.append('llm/retry', { turn: 2, step: 1, ...normal })
|
||||
}).toThrow(/open turn is 1/)
|
||||
|
||||
const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step'))
|
||||
openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
openStep.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => {
|
||||
openStep.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
openStep.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/step 1 is still open/)
|
||||
|
||||
const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step'))
|
||||
noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => {
|
||||
noStep.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/latest closed step is undefined/)
|
||||
|
||||
const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step')
|
||||
expect(() => {
|
||||
wrongStep.append('llm/retry', {
|
||||
turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
wrongStep.append('llm/retry', { turn: 1, step: 2, ...normal })
|
||||
}).toThrow(/latest closed step is 1/)
|
||||
|
||||
const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn')
|
||||
closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
expect(() => {
|
||||
closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('rejects duplicate and out-of-sequence retry schedules', async () => {
|
||||
it('rejects a second retry record for the same step', async () => {
|
||||
const ctx = await setup()
|
||||
const duplicate = closeStep(ctx, 'retry-invariant-duplicate')
|
||||
duplicate.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure,
|
||||
})
|
||||
expect(() => {
|
||||
duplicate.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure,
|
||||
})
|
||||
}).toThrow(/duplicates/)
|
||||
const session = closeStep(ctx, 'retry-invariant-duplicate')
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
|
||||
const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing')
|
||||
nonIncreasing.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure,
|
||||
})
|
||||
nonIncreasing.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
nonIncreasing.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
nonIncreasing.append('step/start', { turn: 2, step: 1 })
|
||||
nonIncreasing.append('step/end', { turn: 2, step: 1 })
|
||||
expect(() => {
|
||||
nonIncreasing.append('llm/retry', {
|
||||
turn: 2, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure,
|
||||
})
|
||||
}).toThrow(/retry-chain position 2/)
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 2 })
|
||||
}).toThrow(/duplicates the retry record/)
|
||||
})
|
||||
|
||||
it('resets retry numbering after a completed chain', async () => {
|
||||
it('binds retry numbering to the provider policy and resets it after success', async () => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, 'retry-invariant-reset')
|
||||
session.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn: 3, step: 1 })
|
||||
session.append('step/end', { turn: 3, step: 1 })
|
||||
|
||||
const mismatch = closeStep(ctx, 'retry-invariant-numbering')
|
||||
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
mismatch.append('step/start', { turn: 2, step: 1 })
|
||||
mismatch.append('step/end', { turn: 2, step: 1 })
|
||||
expect(() => {
|
||||
session.append('llm/retry', {
|
||||
turn: 3, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
mismatch.append('llm/retry', { turn: 2, step: 1, ...normal, retry: 1 })
|
||||
}).toThrow(/must equal provider policy retry 2/)
|
||||
|
||||
const reset = closeStep(ctx, 'retry-invariant-reset')
|
||||
reset.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
reset.append('step/start', { turn: 2, step: 1 })
|
||||
reset.append('assistant/message', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'success' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
}, { surfaceOp: 'append' })
|
||||
reset.append('step/end', { turn: 2, step: 1 })
|
||||
reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
reset.append('step/start', { turn: 3, step: 1 })
|
||||
reset.append('step/end', { turn: 3, step: 1 })
|
||||
expect(() => {
|
||||
reset.append('llm/retry', { turn: 3, step: 1, ...normal })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('starts a fresh retry chain after incomplete predecessor boundaries', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
const missingEnd = ctx.sessions.create(SessionId('retry-invariant-missing-end'))
|
||||
missingEnd.append('user/message', {
|
||||
content: [{ type: 'text', text: 'idle context' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendRetryTurn(missingEnd, 2)
|
||||
|
||||
const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end'))
|
||||
nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
nonFailureEnd.append('user/message', {
|
||||
content: [{ type: 'text', text: 'idle context' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendRetryTurn(nonFailureEnd, 2)
|
||||
|
||||
const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start'))
|
||||
missingStart.append('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 1, failure },
|
||||
})
|
||||
appendRetryTurn(missingStart, 2)
|
||||
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a provider that does not match the failed request route', async () => {
|
||||
const ctx = await setup()
|
||||
const session = closeStep(ctx, 'retry-invariant-provider')
|
||||
expect(() => {
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...always, provider: 'other' })
|
||||
}).toThrow(/does not match the failed request provider mock/)
|
||||
})
|
||||
|
||||
it('validates existing histories on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('retry-invariant-late'))
|
||||
session.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/)
|
||||
})
|
||||
|
||||
it('accepts a valid mixed pre-existing history on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('retry-invariant-late-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('llm/retry', {
|
||||
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
|
||||
})
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -20,6 +20,16 @@ let context: Context | undefined
|
||||
|
||||
class TransientOnceAdapter extends LlmAdapter {
|
||||
requests = 0
|
||||
private readonly retryPolicy = resolveRetryPolicy({
|
||||
mode: 'normal',
|
||||
maxRetries: 1,
|
||||
retryableCodes: ['RATE_LIMIT', 'SERVER'],
|
||||
backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
|
||||
}, 'loader test provider retryPolicy')
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.retryPolicy
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests += 1
|
||||
@@ -75,7 +85,7 @@ describe('real Loader composition', () => {
|
||||
// Real-Loader composition resolves workspace packages through tsx at test
|
||||
// time; first resolution after the host/client program split is slow enough
|
||||
// to trip the default 5s budget on cold caches.
|
||||
it('loads the flat policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => {
|
||||
it('loads provider-supplied policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadYaml([
|
||||
"- name: '@deepseek-ai/dsh-llm'",
|
||||
"- name: '@deepseek-ai/dsh-session'",
|
||||
@@ -83,12 +93,6 @@ describe('real Loader composition', () => {
|
||||
"- name: '@deepseek-ai/dsh-tools'",
|
||||
"- name: '@deepseek-ai/dsh-agent'",
|
||||
"- name: '@deepseek-ai/dsh-llm-retry'",
|
||||
' config:',
|
||||
' maxTransientRetries: 1',
|
||||
' initialDelayMs: 1',
|
||||
' maxDelayMs: 1',
|
||||
' jitterRatio: 0',
|
||||
' retryableCodes: [RATE_LIMIT, SERVER]',
|
||||
"- name: '@deepseek-ai/dsh-agent-loop'",
|
||||
])
|
||||
|
||||
|
||||
@@ -34,12 +34,18 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
|
||||
const session = ctx.sessions.create(SessionId(`retry-${kind}`))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const event = session.append('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
mode: 'always',
|
||||
policyKey: '["always",500,10000,0.1]',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 750,
|
||||
failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,13 +39,17 @@ async function harness(
|
||||
apiKey: 'mock-key',
|
||||
baseURL,
|
||||
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
|
||||
retryPolicy: {
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
backoff: {
|
||||
initialDelayMs: options.initialDelayMs ?? 10,
|
||||
maxDelayMs: options.initialDelayMs ?? 10,
|
||||
jitterRatio: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
await ctx.plugin(Retry, {
|
||||
maxTransientRetries: 2,
|
||||
initialDelayMs: options.initialDelayMs ?? 10,
|
||||
maxDelayMs: options.initialDelayMs ?? 10,
|
||||
jitterRatio: 0,
|
||||
})
|
||||
await ctx.plugin(Retry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
return ctx
|
||||
}
|
||||
|
||||
25
packages/llm/llm-retry/tsdown.config.ts
Normal file
25
packages/llm/llm-retry/tsdown.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -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: 3efb3ece3caadeaceaa3c504ba4b10ddb951127a
|
||||
README.zh.md: 4af8b8d08cc96ff0e36b10b15e1d86afd43004c9
|
||||
README.md: 2328188e420df6de60f024982a31d37a858a303e
|
||||
README.zh.md: 586767a9e790fa68d27673836700fd789ce6a180
|
||||
|
||||
@@ -12,15 +12,16 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
|
||||
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved.
|
||||
- `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 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 an adapter-configured default without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration 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` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
|
||||
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
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`.
|
||||
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
|
||||
|
||||
@@ -34,7 +35,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
@@ -76,7 +77,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No default retry/caching/rate-limit policy ships in this service** — `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 an optional policy plugin 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 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.
|
||||
- **`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.
|
||||
|
||||
@@ -12,15 +12,16 @@
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber dispose。
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
|
||||
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。
|
||||
- `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.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
|
||||
|
||||
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。
|
||||
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。
|
||||
|
||||
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,适配器则可以接受 `listModels()` 中不存在的模型 id;消费方禁止因模型未列出而拒绝请求。返回的元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
### 扩展点
|
||||
|
||||
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。
|
||||
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。
|
||||
- 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`。
|
||||
|
||||
### 内容块词汇(`types.ts`)
|
||||
@@ -76,7 +77,7 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **本服务不内置默认重试/缓存/速率限制策略**:`llm/stream` 仍是单次尝试调用包装 seam;agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败。`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选策略插件。
|
||||
- **本服务不执行重试、缓存或速率限制**:提供方注册会存储重试策略,但 `llm/stream` 仍是单次尝试调用包装 seam。agent loop 会将已验证模型请求失败单独提供给 `agent/request-error`,其默认行为是保留原始失败;`@deepseek-ai/dsh-llm-retry` 是共享示例 spine 加载的可选执行器。
|
||||
- **`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` 只处理核心块 kind**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。
|
||||
|
||||
@@ -38,11 +38,16 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
|
||||
import { HarnessError } from './error.ts'
|
||||
import type { LlmFailure, StreamChunk } from './types.ts'
|
||||
import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
|
||||
/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */
|
||||
export type AdapterFailureScope = WeakMap<Error, LlmFailure>
|
||||
/** Call-local facts captured when one model call enters its final adapter boundary. */
|
||||
export interface AdapterFailureScope {
|
||||
/** Errors and normalized facts proven to originate in this call's final adapter boundary. */
|
||||
readonly failures: WeakMap<Error, LlmFailure>
|
||||
/** Immutable policy of the exact adapter registration selected for this call. */
|
||||
retryPolicy?: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
|
||||
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
|
||||
@@ -54,7 +60,7 @@ export function markLlmAdapterFailure(
|
||||
message: errorMessage(error),
|
||||
code: harnessErrorCode(error),
|
||||
})
|
||||
failures.set(error, failure)
|
||||
failures.failures.set(error, failure)
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -136,7 +142,7 @@ export function isLlmAdapterFailure(
|
||||
value: unknown,
|
||||
): value is Error & { code?: string } {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error && failures !== undefined && failures.has(value)
|
||||
return value instanceof Error && failures !== undefined && failures.failures.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,5 +157,18 @@ export function llmFailureOf(
|
||||
value: unknown,
|
||||
): LlmFailure | undefined {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error ? failures?.get(value) : undefined
|
||||
return value instanceof Error ? failures?.failures.get(value) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the retry policy of the exact registration selected at this call's
|
||||
* final adapter boundary. The policy remains available after that registration
|
||||
* is disposed or replaced; absence means no final adapter served the call.
|
||||
* @param stream - the exact stream returned by the model call.
|
||||
* @returns the immutable serving-registration policy, or `undefined`.
|
||||
*/
|
||||
export function llmRetryPolicyOf(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
): ResolvedRetryPolicy | undefined {
|
||||
return adapterFailureScopes.get(stream)?.retryPolicy
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
Message,
|
||||
StreamChunk,
|
||||
} from './types.ts'
|
||||
import { resolveRetryPolicy } from './retry-policy.ts'
|
||||
import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
import type { ProviderRequestId } from './brand.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig } from './call-config.ts'
|
||||
@@ -28,10 +30,11 @@ export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
export * from './types.ts'
|
||||
export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
|
||||
export type { LlmCallConfig } from './call-config.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -134,6 +137,15 @@ export abstract class LlmAdapter {
|
||||
return { id: provider, name: provider }
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the provider-owned retry policy captured with this route.
|
||||
* @param _provider - a route passed to `registerAdapter()` for this instance.
|
||||
* @returns a resolved policy, or `undefined` to use the normal defaults.
|
||||
*/
|
||||
providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* List models this adapter can currently advertise for one owned provider.
|
||||
* The result is advisory: an adapter may accept unlisted model ids, and
|
||||
@@ -204,7 +216,13 @@ export class LlmService extends Service {
|
||||
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
|
||||
}
|
||||
unique.add(provider)
|
||||
registrations.push({ adapter, provider: { id: info.id, name: info.name } })
|
||||
const retryPolicy = adapter.providerRetryPolicy(provider)
|
||||
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
|
||||
registrations.push({
|
||||
adapter,
|
||||
provider: { id: info.id, name: info.name },
|
||||
retryPolicy,
|
||||
})
|
||||
}
|
||||
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
|
||||
yield () => {
|
||||
@@ -224,6 +242,15 @@ export class LlmService extends Service {
|
||||
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the retry policy captured when one provider route was registered.
|
||||
* @param provider - registered provider route to inspect.
|
||||
* @returns the provider-owned policy, with normal defaults already resolved.
|
||||
*/
|
||||
providerRetryPolicy(provider: string): ResolvedRetryPolicy {
|
||||
return this.registration(provider).retryPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models advertised by one registered provider. Catalog membership
|
||||
* is advisory and never changes routing or request validation.
|
||||
@@ -459,6 +486,7 @@ export class LlmService extends Service {
|
||||
let iterator: AsyncIterator<StreamChunk>
|
||||
try {
|
||||
const registration = prepared?.registration ?? this.registration(options.provider)
|
||||
failures.retryPolicy = registration.retryPolicy
|
||||
const resolvedConfig = prepared === undefined
|
||||
? await this.resolveCallConfigFor(registration, options, options.signal)
|
||||
: prepared.config
|
||||
@@ -530,7 +558,7 @@ export class LlmService extends Service {
|
||||
options: GenerateOptions,
|
||||
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const failures: AdapterFailureScope = new WeakMap<Error, LlmFailure>()
|
||||
const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() }
|
||||
const stream = this.ctx.waterfall(
|
||||
this,
|
||||
'llm/stream',
|
||||
@@ -544,6 +572,7 @@ export class LlmService extends Service {
|
||||
interface AdapterRegistration {
|
||||
readonly adapter: LlmAdapter
|
||||
readonly provider: LlmProviderInfo
|
||||
readonly retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
export default LlmService
|
||||
|
||||
191
packages/llm/llm/src/retry-policy.ts
Normal file
191
packages/llm/llm/src/retry-policy.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/retry-policy
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { EMPTY_RESPONSE_CODE } from './error.ts'
|
||||
|
||||
const DEFAULT_MAX_RETRIES = 2
|
||||
const DEFAULT_INITIAL_DELAY_MS = 500
|
||||
const DEFAULT_MAX_DELAY_MS = 10_000
|
||||
const DEFAULT_JITTER_RATIO = 0.1
|
||||
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
||||
EMPTY_RESPONSE_CODE,
|
||||
'RATE_LIMIT',
|
||||
'SERVER',
|
||||
'TIMEOUT',
|
||||
'TRANSPORT',
|
||||
])
|
||||
|
||||
/** Bounded exponential backoff with symmetric jitter around each local delay. */
|
||||
export interface BackoffConfig {
|
||||
/** Initial local exponential-backoff delay in milliseconds (default 500). */
|
||||
initialDelayMs?: number
|
||||
/** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */
|
||||
maxDelayMs?: number
|
||||
/** Symmetric random multiplier range around one (default 0.1). */
|
||||
jitterRatio?: number
|
||||
}
|
||||
|
||||
/** Current bounded transient retry behavior for one provider route. */
|
||||
export interface NormalRetryPolicyConfig {
|
||||
/** Retry only configured transient failure codes. */
|
||||
mode: 'normal'
|
||||
/** Maximum eligible retries after the first request (default 2). */
|
||||
maxRetries?: number
|
||||
/** Stable failure codes eligible for this policy. */
|
||||
retryableCodes?: string[]
|
||||
/** Local exponential-backoff and jitter configuration. */
|
||||
backoff?: BackoffConfig
|
||||
}
|
||||
|
||||
/** Unbounded retry behavior for every model-request failure on one provider route. */
|
||||
export interface AlwaysRetryPolicyConfig {
|
||||
/** Retry every model-request failure until success, cancellation, or disposal. */
|
||||
mode: 'always'
|
||||
/** Local exponential-backoff and jitter configuration. */
|
||||
backoff?: BackoffConfig
|
||||
}
|
||||
|
||||
/** Provider-owned model-request retry policy configuration. */
|
||||
export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig
|
||||
|
||||
/** Fully resolved backoff shared by both retry modes. */
|
||||
export interface ResolvedRetryBackoff {
|
||||
readonly initialDelayMs: number
|
||||
readonly maxDelayMs: number
|
||||
readonly jitterRatio: number
|
||||
}
|
||||
|
||||
/** Fully resolved bounded transient retry policy. */
|
||||
export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {
|
||||
readonly mode: 'normal'
|
||||
readonly maxRetries: number
|
||||
readonly retryableCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** Fully resolved unbounded retry policy. */
|
||||
export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {
|
||||
readonly mode: 'always'
|
||||
}
|
||||
|
||||
/** Immutable provider policy captured when its adapter route is registered. */
|
||||
export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy
|
||||
|
||||
const backoffSchema: z<BackoffConfig> = z.object({
|
||||
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
||||
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
||||
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
|
||||
})
|
||||
|
||||
const normalPolicySchema: z<NormalRetryPolicyConfig> = z.object({
|
||||
mode: z.const('normal').required(),
|
||||
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
||||
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
||||
backoff: backoffSchema,
|
||||
})
|
||||
|
||||
const alwaysPolicySchema: z<AlwaysRetryPolicyConfig> = z.object({
|
||||
mode: z.const('always').required(),
|
||||
backoff: backoffSchema,
|
||||
})
|
||||
|
||||
/** Cordis schema embedded by each concrete provider configuration. */
|
||||
export const RetryPolicySchema: z<RetryPolicyConfig> = z.union([
|
||||
normalPolicySchema,
|
||||
alwaysPolicySchema,
|
||||
])
|
||||
|
||||
const NORMAL_POLICY_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'maxRetries', 'retryableCodes', 'backoff',
|
||||
])
|
||||
const ALWAYS_POLICY_KEYS: ReadonlySet<string> = new Set(['mode', 'backoff'])
|
||||
const BACKOFF_KEYS: ReadonlySet<string> = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio'])
|
||||
|
||||
function validateKeys(value: object, allowed: ReadonlySet<string>, path: string): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) throw new Error(`${path}: unknown key "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBackoff(config: BackoffConfig | undefined, path: string): ResolvedRetryBackoff {
|
||||
if (config !== undefined) validateKeys(config, BACKOFF_KEYS, path)
|
||||
const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS
|
||||
const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
||||
const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO
|
||||
|
||||
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (initialDelayMs > maxDelayMs) {
|
||||
throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`)
|
||||
}
|
||||
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
|
||||
throw new Error(`${path}.jitterRatio must be between 0 and 1`)
|
||||
}
|
||||
|
||||
return Object.freeze({ initialDelayMs, maxDelayMs, jitterRatio })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, default, and detach one provider-owned retry policy.
|
||||
* @param config - optional provider configuration; omission selects normal defaults.
|
||||
* @param path - diagnostic path naming the provider config that owns the value.
|
||||
* @returns an immutable policy safe to capture in provider registration state.
|
||||
*/
|
||||
export function resolveRetryPolicy(
|
||||
config: RetryPolicyConfig | undefined,
|
||||
path: string,
|
||||
): ResolvedRetryPolicy {
|
||||
if (config === undefined) {
|
||||
return Object.freeze({
|
||||
mode: 'normal',
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
retryableCodes: DEFAULT_RETRYABLE_CODES,
|
||||
...resolveBackoff(undefined, `${path}.backoff`),
|
||||
})
|
||||
}
|
||||
|
||||
switch (config.mode) {
|
||||
case 'normal': {
|
||||
validateKeys(config, NORMAL_POLICY_KEYS, path)
|
||||
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||
const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES]
|
||||
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) {
|
||||
throw new Error(`${path}.maxRetries must be a non-negative safe integer`)
|
||||
}
|
||||
if (retryableCodes.length === 0) {
|
||||
throw new Error(`${path}.retryableCodes must not be empty`)
|
||||
}
|
||||
if (retryableCodes.some(code => typeof code !== 'string' || code.length === 0)) {
|
||||
throw new Error(`${path}.retryableCodes must contain only non-empty strings`)
|
||||
}
|
||||
if (new Set(retryableCodes).size !== retryableCodes.length) {
|
||||
throw new Error(`${path}.retryableCodes must not contain duplicates`)
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: 'normal',
|
||||
maxRetries,
|
||||
retryableCodes: Object.freeze([...retryableCodes]),
|
||||
...resolveBackoff(config.backoff, `${path}.backoff`),
|
||||
})
|
||||
}
|
||||
case 'always':
|
||||
validateKeys(config, ALWAYS_POLICY_KEYS, path)
|
||||
return Object.freeze({
|
||||
mode: 'always',
|
||||
...resolveBackoff(config.backoff, `${path}.backoff`),
|
||||
})
|
||||
default:
|
||||
throw new Error(`${path}.mode must be "normal" or "always"`)
|
||||
}
|
||||
}
|
||||
85
packages/llm/llm/tests/retry-policy.spec.ts
Normal file
85
packages/llm/llm/tests/retry-policy.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveRetryPolicy,
|
||||
RetryPolicySchema,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
describe('provider retry policy', () => {
|
||||
it('resolves immutable normal defaults', () => {
|
||||
const policy = resolveRetryPolicy(undefined, 'provider.retryPolicy')
|
||||
|
||||
expect(policy).toEqual({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
jitterRatio: 0.1,
|
||||
})
|
||||
expect(Object.isFrozen(policy)).toBe(true)
|
||||
if (policy.mode !== 'normal') throw new Error('expected normal policy')
|
||||
expect(Object.isFrozen(policy.retryableCodes)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves and detaches a configured normal policy', () => {
|
||||
const retryableCodes = ['BUSY']
|
||||
const config: RetryPolicyConfig = {
|
||||
mode: 'normal',
|
||||
maxRetries: 4,
|
||||
retryableCodes,
|
||||
backoff: {
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const policy = resolveRetryPolicy(config, 'provider.retryPolicy')
|
||||
retryableCodes.push('LATE')
|
||||
|
||||
expect(policy).toEqual({
|
||||
mode: 'normal',
|
||||
maxRetries: 4,
|
||||
retryableCodes: ['BUSY'],
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves always mode with default backoff', () => {
|
||||
expect(resolveRetryPolicy({ mode: 'always' }, 'provider.retryPolicy')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
jitterRatio: 0.1,
|
||||
})
|
||||
expect(RetryPolicySchema).toBeDefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ mode: 'normal', maxRetries: -1 }, /maxRetries/],
|
||||
[{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/],
|
||||
[{ mode: 'normal', maxRetries: Number.MAX_SAFE_INTEGER + 1 }, /maxRetries/],
|
||||
[{ mode: 'always', backoff: { initialDelayMs: 0 } }, /initialDelayMs/],
|
||||
[{ mode: 'normal', backoff: { maxDelayMs: Number.POSITIVE_INFINITY } }, /maxDelayMs/],
|
||||
[{ mode: 'normal', backoff: { initialDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /initialDelayMs/],
|
||||
[{ mode: 'always', backoff: { maxDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /maxDelayMs/],
|
||||
[{ mode: 'normal', backoff: { initialDelayMs: 20, maxDelayMs: 10 } }, /less than or equal/],
|
||||
[{ mode: 'always', backoff: { jitterRatio: 1.1 } }, /jitterRatio/],
|
||||
[{ mode: 'normal', retryableCodes: [] }, /must not be empty/],
|
||||
[{ mode: 'normal', retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/],
|
||||
[{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/],
|
||||
[{ mode: 'normal', retryableCodes: [429] }, /non-empty strings/],
|
||||
[{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/],
|
||||
[{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/],
|
||||
[{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/],
|
||||
[{ mode: 'sometimes' }, /mode must be "normal" or "always"/],
|
||||
] as const)('rejects invalid policy %#', (config, message) => {
|
||||
expect(() => {
|
||||
resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy')
|
||||
}).toThrow(message)
|
||||
})
|
||||
})
|
||||
@@ -10,8 +10,10 @@ import LlmService, {
|
||||
LlmAdapter,
|
||||
LlmError,
|
||||
llmFailureOf,
|
||||
llmRetryPolicyOf,
|
||||
ProviderRequestId,
|
||||
ReasoningEffortId,
|
||||
resolveRetryPolicy,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
@@ -173,6 +175,72 @@ describe('LlmService', () => {
|
||||
expect(chunks).toEqual(SCRIPT)
|
||||
})
|
||||
|
||||
it('captures provider-owned retry policy at registration and defaults omission', async () => {
|
||||
const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy')
|
||||
const adapter = new class extends ScriptedAdapter {
|
||||
override providerRetryPolicy(provider: string) {
|
||||
return provider === 'configured' ? configured : undefined
|
||||
}
|
||||
}(SCRIPT)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['configured', 'defaulted'], adapter)
|
||||
|
||||
expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured)
|
||||
expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({
|
||||
mode: 'normal',
|
||||
maxRetries: 2,
|
||||
})
|
||||
expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow(
|
||||
expect.objectContaining({ code: 'NO_ADAPTER' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the serving registration policy on an in-flight call after route replacement', async () => {
|
||||
const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy')
|
||||
const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy')
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const failure = new LlmError('old route failed', 'AUTH')
|
||||
const oldAdapter = new class extends LlmAdapter {
|
||||
override providerRetryPolicy(): typeof oldPolicy {
|
||||
return oldPolicy
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
throw failure
|
||||
}
|
||||
}()
|
||||
const newAdapter = new class extends ScriptedAdapter {
|
||||
override providerRetryPolicy(): typeof newPolicy {
|
||||
return newPolicy
|
||||
}
|
||||
}(SCRIPT)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const disposeOld = ctx.llm.registerAdapter(['route'], oldAdapter)
|
||||
const stream = ctx.llm.stream({ provider: 'route', model: 'model', messages: [] })
|
||||
const outcome = (async (): Promise<unknown> => {
|
||||
try {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
await entered.promise
|
||||
|
||||
disposeOld()
|
||||
ctx.llm.registerAdapter(['route'], newAdapter)
|
||||
release.resolve(undefined)
|
||||
|
||||
expect(await outcome).toBe(failure)
|
||||
expect(llmRetryPolicyOf(stream)).toBe(oldPolicy)
|
||||
expect(ctx.llm.providerRetryPolicy('route')).toBe(newPolicy)
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered providers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -187,6 +255,7 @@ describe('LlmService', () => {
|
||||
expect((caught as LlmError).code).toBe('NO_ADAPTER')
|
||||
expect((caught as LlmError).message).toContain('no adapter registered')
|
||||
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
|
||||
expect(llmRetryPolicyOf(stream)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user