From d0df50aa8eee0c98b7eacadcdbc797f7780de565 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:43:53 +0800 Subject: [PATCH] fix: retain LLM HTTP status metadata --- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/tests/adapter.spec.ts | 7 ++++++- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/index.ts | 6 ++++-- packages/llm/llm/tests/service.spec.ts | 5 ++--- packages/support/llm-replay/src/index.ts | 4 ++-- packages/support/llm-replay/tests/llm-replay.spec.ts | 10 +++++----- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f6051d1ccd..30760a8fbc 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -76,7 +76,7 @@ export class DeepSeekAdapter extends LlmAdapter { // Only swallow error-body parsing: status and code are already captured, // so malformed gateway JSON must not mask the actionable HTTP failure. } - throw new LlmError(message, code) + throw new LlmError(message, code, response.status) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1f1aef3f05..46f123a1c7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -158,7 +158,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior]) + const server = await mockServer([behavior, behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -166,6 +166,11 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) + // The numeric HTTP status is carried on the error for explicit handling. + await expect( + assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + .catch((error: unknown) => (error as LlmError).status), + ).resolves.toBe(status) }) it('keeps the status-line message for JSON error bodies without a message', async () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index c8f2002f9f..296fd0c3d3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -42,7 +42,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `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 harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. ### Real adapters diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8f2c9b4f39..08f3f54c51 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -42,10 +42,12 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; + * `status` carries the HTTP status when the error originated from a non-2xx + * provider response (absent for protocol/usage errors that have no HTTP status). */ export class LlmError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { + constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 125a810261..f669069c44 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -79,12 +79,11 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const cause = new Error('root cause') - const err = new LlmError('boom', 'AUTH', { cause }) + const err = new LlmError('boom', 'AUTH', 401) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.cause).toBe(cause) + expect(err.status).toBe(401) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index b7e4e30a46..2e509973e5 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } | { kind: 'hang' } /** Resolved plugin configuration. */ @@ -221,7 +221,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code) + throw new LlmError(entry.message, entry.code, entry.status) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index aa3fea20d5..ac52ec11c9 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -231,12 +231,12 @@ describe('installLlmReplay (through the real waterfall)', () => { expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -245,7 +245,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) expect(seen).toEqual(partial) }) @@ -350,7 +350,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService)