From bb1c8fa27da12f2432afaf3d5c04fd8299df73e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:09:14 +0800 Subject: [PATCH] fix(llm): restore pruned status contract --- docs/cordis-catalog/services.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 6 +++--- packages/llm/llm-deepseek/tests/adapter.spec.ts | 7 +------ packages/llm/llm-pi-ai/README.md | 2 +- 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 +++++----- 9 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..48fb887d27 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -128,7 +128,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30760a8fbc..978dec65c8 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -73,10 +73,10 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Only swallow error-body parsing: status and code are already captured, - // so malformed gateway JSON must not mask the actionable HTTP failure. + // Only swallow error-body parsing: the stable code and status-line message + // are already captured, so malformed gateway JSON must not mask the failure. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, code) } 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 46f123a1c7..1f1aef3f05 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, behavior]) + const server = await mockServer([behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -166,11 +166,6 @@ 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-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index dd1cb5b48c..281a4f13be 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -55,6 +55,6 @@ Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK - **`tool_choice` is not mapped** — same MVP contract as llm-deepseek. - **In-history `system`-role messages fold into `user`-role wire messages** — pi-ai exposes a single `systemPrompt` slot, diverging from the hand-rolled twin's `role: 'system'` passthrough. -- **`LlmError.status` is never set** — pi-ai reports failures as in-stream events with no HTTP status, so error codes are regex-classified from the error text. +- **Provider HTTP status is unavailable** — pi-ai reports failures as in-stream events, so stable error codes are regex-classified from the error text. - **`buildModel` hardcodes descriptor metadata** — `contextWindow: 128000`, `maxTokens: 64000`, zero cost, identically for every registered model name; not configurable. - **pi-ai's built-in retries are disabled (`maxRetries: 0`)** — failures surface immediately; retry policy belongs to `llm/stream` listeners. diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 296fd0c3d3..c8f2002f9f 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`; `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. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. ### Real adapters diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 08f3f54c51..8f2c9b4f39 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -42,12 +42,10 @@ 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; - * `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). + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { + constructor(message: string, code: string, 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 f669069c44..125a810261 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -79,11 +79,12 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const err = new LlmError('boom', 'AUTH', 401) + const cause = new Error('root cause') + const err = new LlmError('boom', 'AUTH', { cause }) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.status).toBe(401) + expect(err.cause).toBe(cause) }) 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 2e509973e5..b7e4e30a46 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; status?: number } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { 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, entry.status) + throw new LlmError(entry.message, entry.code) 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 ac52ec11c9..aa3fea20d5 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', status: 401 }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] 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 code/status, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with its stable code, 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', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), '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', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) 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', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService)