Merge remote-tracking branch 'origin/master' into codex/provider-retry-policy

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	packages/llm/llm-retry/README.md
#	packages/llm/llm-retry/src/index.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
This commit is contained in:
Turtle
2026-07-25 16:05:05 +08:00
156 changed files with 1412 additions and 1079 deletions

View File

@@ -55,6 +55,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
### Real adapters

View File

@@ -27,6 +27,17 @@ export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED'
/** Canonical provider-neutral code for an exhausted account quota or balance. */
export const QUOTA_EXCEEDED_CODE = 'QUOTA'
/**
* Canonical provider-neutral code for a response that completed normally but
* carried no content blocks at all. Providers occasionally emit a degenerate
* completion (a terminal stop with zero output); adapters classify it as this
* failure instead of yielding an empty assistant message, because an empty
* message silently ends the turn with nothing for the user or the loop to act
* on. The attempt produced nothing durable, so retry policy treats it as safe
* to repeat.
*/
export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`

View File

@@ -9,12 +9,19 @@
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(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
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 {
@@ -159,7 +166,7 @@ export function resolveRetryPolicy(
if (retryableCodes.length === 0) {
throw new Error(`${path}.retryableCodes must not be empty`)
}
if (retryableCodes.some(code => code.length === 0)) {
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) {

View File

@@ -13,7 +13,7 @@ describe('provider retry policy', () => {
expect(policy).toEqual({
mode: 'normal',
maxRetries: 2,
retryableCodes: ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
initialDelayMs: 500,
maxDelayMs: 10_000,
jitterRatio: 0.1,
@@ -72,6 +72,7 @@ describe('provider retry policy', () => {
[{ 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"/],