Merge latest invariant registration gate

This commit is contained in:
Tianyi Cui
2026-07-20 20:29:29 +08:00
22 changed files with 329 additions and 81 deletions

View File

@@ -42,7 +42,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. A transport failure before any response (DNS, refused connection, TLS, proxy) throws `NETWORK` naming the configured endpoint and chaining fetch's `TypeError: fetch failed` as `cause`, so `errorChain` renders the underlying diagnosis; an abort keeps its `DOMException` so the loop classifies it as cancellation. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
## Testing

View File

@@ -81,23 +81,43 @@ export class DeepSeekAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
// Prepared outside the try so the NETWORK label below covers exactly the
// transport boundary, never a serialization failure.
const payload = JSON.stringify(body)
const headers = {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
}
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
// outweighs its additional runtime dependencies.
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},
})
let response: Response
try {
response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers,
body: payload,
...options.signal ? { signal: options.signal } : {},
})
} catch (error: unknown) {
// An aborted request rethrows its original rejection (the signal's abort
// reason) so the loop classifies it as cancellation, not a provider failure.
if (options.signal?.aborted) throw error
// fetch wraps every transport failure (DNS, refused connection, TLS,
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
// lives on `cause`. Wrapping with the endpoint and chaining the cause
// lets `errorChain` render the full diagnosis at every reporting seam.
throw new LlmError(
`DeepSeek API request to ${this.options.baseURL} failed`,
'NETWORK',
{ cause: error },
)
}
if (!response.ok) {
let message = `DeepSeek API error (HTTP ${response.status})`

View File

@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
@@ -228,6 +228,39 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('wraps a transport failure in NETWORK with the fetch cause chain in the message', async () => {
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
// whose actionable detail (ECONNREFUSED) lives on `cause`.
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
const llmError = caught as LlmError
expect(llmError.code).toBe('NETWORK')
expect(llmError.message).toContain('http://127.0.0.1:1')
expect(llmError.cause).toBeInstanceOf(TypeError)
// The chain renderer reaches the transport diagnosis through the cause.
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
})
it('keeps an abort rejection unwrapped so the loop classifies it as cancellation', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
} catch (error: unknown) {
caught = error
}
expect(caught).not.toBeInstanceOf(LlmError)
expect((caught as Error).name).toBe('AbortError')
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(

View File

@@ -48,6 +48,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `HarnessError` — base class for the product error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf product package every other product package imports, so a single base is shared without a new dependency edge. Per-package errors such as `LlmError` and `ToolArgsError` extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract.
- `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.
### Real adapters

View File

@@ -62,6 +62,51 @@ export function isContextWindowExceededError(detail: string): boolean {
|| EXCEEDS_MODEL_CONTEXT.test(detail)
}
/**
* Render a thrown value with its full `cause` chain and AggregateError
* members, so transport wrappers like undici's `TypeError: fetch failed`
* surface the underlying failure instead of masking it. Diagnostic-surface
* rendering only (messages, notices, logs) — never parse the result; route on
* {@link HarnessError.code}.
* @param value - the caught value (`unknown` in catch clauses).
* @returns the outermost message first, each cause appended with `: ` (skipped
* when it repeats the wrapper message verbatim), and AggregateError members
* bracketed and `; `-joined.
*/
export function errorChain(value: unknown): string {
// Tracks the active recursion path (entries removed on exit), so only true
// cycles are flagged and a diamond-shared cause still renders in full.
const path = new Set<unknown>()
const render = (current: unknown): string => {
if (path.has(current)) return '<circular cause>'
path.add(current)
try {
if (!(current instanceof Error)) return String(current)
const message = current.message === '' ? current.name : current.message
const members = current instanceof AggregateError && current.errors.length > 0
? ` [${current.errors.map(render).join('; ')}]`
: ''
const causeText = current.cause === undefined || current.cause === null
? ''
: render(current.cause)
// Wrappers like `new HarnessError(String(value), code, { cause: value })`
// repeat their cause verbatim; rendering it again would only add noise.
const cause = causeText === '' || causeText === message ? '' : `: ${causeText}`
return `${message}${members}${cause}`
} catch {
// Only hostile coercion or hostile accessors (a throwing toString /
// Symbol.toPrimitive on a non-Error, or a throwing message/name/cause/
// errors getter on an Error subclass): this renderer feeds UI notices
// and logs, so nothing may escape. Inner frames catch their own throws,
// so only the hostile node collapses, not the whole chain.
return '<unrenderable value>'
} finally {
path.delete(current)
}
}
return render(value)
}
/**
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
* @param value - the caught value (`unknown` in catch clauses).

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, {
errorChain,
GenerateOptions,
HarnessError,
isContextWindowExceededError,
@@ -81,6 +82,50 @@ describe('LlmService', () => {
expect(isContextWindowExceededError('context window size must be positive')).toBe(false)
})
it('errorChain renders the full cause chain of a wrapped transport failure', () => {
const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') })
expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443')
})
it('errorChain renders AggregateError members (Happy Eyeballs multi-address failures)', () => {
const aggregate = new AggregateError(
[new Error('connect ECONNREFUSED ::1:443'), new Error('connect ECONNREFUSED 127.0.0.1:443')],
'',
)
const wrapped = new TypeError('fetch failed', { cause: aggregate })
expect(errorChain(wrapped)).toBe(
'fetch failed: AggregateError [connect ECONNREFUSED ::1:443; connect ECONNREFUSED 127.0.0.1:443]',
)
})
it('errorChain survives non-Error values, hostile coercion, and circular causes', () => {
expect(errorChain('plain string')).toBe('plain string')
expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('<unrenderable value>')
const circular = new Error('outer')
circular.cause = circular
expect(errorChain(circular)).toBe('outer: <circular cause>')
// A hostile accessor collapses only its own node, not the whole chain.
const hostileNode = new Error('node')
Object.defineProperty(hostileNode, 'message', { get() { throw new Error('hostile getter') } })
expect(errorChain(new Error('outer', { cause: hostileNode }))).toBe('outer: <unrenderable value>')
// A diamond-shared (non-cyclic) cause renders in full on both paths.
const shared = new Error('shared')
const diamond = new AggregateError([new Error('a', { cause: shared }), new Error('b', { cause: shared })], 'agg')
expect(errorChain(diamond)).toBe('agg [a: shared; b: shared]')
})
it('errorChain falls back to the error name, skips empty aggregates, and stops at null causes', () => {
expect(errorChain(new TypeError('', { cause: null }))).toBe('TypeError')
expect(errorChain(new AggregateError([], 'all failed'))).toBe('all failed')
})
it('errorChain collapses a cause that repeats the wrapper message verbatim', () => {
// The `new HarnessError(String(value), code, { cause: value })` normalization
// pattern repeats its cause; rendering it twice would only add noise.
const wrapped = new HarnessError('boom', 'UNKNOWN', { cause: 'boom' })
expect(errorChain(wrapped)).toBe('boom')
})
it('routes stream() to the registered adapter', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)