Merge latest master into invariant service seam
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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})`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, {
|
||||
errorChain,
|
||||
GenerateOptions,
|
||||
HarnessError,
|
||||
isContextWindowExceededError,
|
||||
@@ -80,6 +81,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)
|
||||
|
||||
Reference in New Issue
Block a user