fix(llm): restore pruned status contract

This commit is contained in:
Tianyi Cui
2026-07-15 23:09:14 +08:00
parent abac7e4f44
commit bb1c8fa27d
9 changed files with 19 additions and 25 deletions

View File

@@ -128,7 +128,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) 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` ## `ctx.permission` — `PermissionService`

View File

@@ -73,10 +73,10 @@ export class DeepSeekAdapter extends LlmAdapter {
const parsed = await response.json() as WireError const parsed = await response.json() as WireError
if (parsed.error?.message) message = parsed.error.message if (parsed.error?.message) message = parsed.error.message
} catch { } catch {
// Only swallow error-body parsing: status and code are already captured, // Only swallow error-body parsing: the stable code and status-line message
// so malformed gateway JSON must not mask the actionable HTTP failure. // 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) { if (!response.body) {
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')

View File

@@ -158,7 +158,7 @@ describe('DeepSeekAdapter against a mock server', () => {
status, status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), 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) const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`) .rejects.toThrow(`failed with ${status}`)
@@ -166,11 +166,6 @@ describe('DeepSeekAdapter against a mock server', () => {
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code), .catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(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 () => { it('keeps the status-line message for JSON error bodies without a message', async () => {

View File

@@ -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. - **`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. - **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. - **`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. - **pi-ai's built-in retries are disabled (`maxRetries: 0`)** — failures surface immediately; retry policy belongs to `llm/stream` listeners.

View File

@@ -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()`. - `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. - `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. - `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 ### Real adapters

View File

@@ -42,12 +42,10 @@ declare module 'cordis' {
/** /**
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the * 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 { 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) super(message, code, options)
this.name = 'LlmError' this.name = 'LlmError'
} }

View File

@@ -79,11 +79,12 @@ describe('LlmService', () => {
it('LlmError extends the shared HarnessError base', async () => { it('LlmError extends the shared HarnessError base', async () => {
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') 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(err).toBeInstanceOf(HarnessError)
expect(isHarnessError(err)).toBe(true) expect(isHarnessError(err)).toBe(true)
expect(err.code).toBe('AUTH') 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 () => { it('HarnessError carries a code, names itself by subclass, and chains cause', async () => {

View File

@@ -20,7 +20,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
*/ */
export type ReplayEntry = export type ReplayEntry =
| { kind: 'chunks'; chunks: StreamChunk[] } | { kind: 'chunks'; chunks: StreamChunk[] }
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string }
| { kind: 'hang' } | { kind: 'hang' }
/** Resolved plugin configuration. */ /** Resolved plugin configuration. */
@@ -221,7 +221,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
if (signal?.aborted) throw new Error('aborted') if (signal?.aborted) throw new Error('aborted')
yield chunk yield chunk
} }
throw new LlmError(entry.message, entry.code, entry.status) throw new LlmError(entry.message, entry.code)
case 'hang': case 'hang':
// Replay a stream that stalls until cancelled (mirrors MockAdapter): one // Replay a stream that stalls until cancelled (mirrors MockAdapter): one
// chunk, then wait for abort and surface it as the consumer expects. // chunk, then wait for abort and surface it as the consumer expects.

View File

@@ -175,7 +175,7 @@ describe('loadReplayScript', () => {
it('uses the sidecar override when present, ignoring the JSONL', () => { it('uses the sidecar override when present, ignoring the JSONL', () => {
writeFileSync(file, sessionJsonl([]), 'utf8') writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json') 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') writeFileSync(overrideFile, JSON.stringify(override), 'utf8')
expect(loadReplayScript({ file, overrideFile })).toEqual(override) 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) 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') writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json') const overrideFile = join(dir, 'replay.override.json')
const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }]
writeFileSync(overrideFile, JSON.stringify([ writeFileSync(overrideFile, JSON.stringify([
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' },
]), 'utf8') ]), 'utf8')
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
@@ -245,7 +245,7 @@ describe('installLlmReplay (through the real waterfall)', () => {
const seen: StreamChunk[] = [] const seen: StreamChunk[] = []
await expect((async () => { await expect((async () => {
for await (const c of ctx.llm.stream({ model: 'm', messages: [] })) seen.push(c) 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) expect(seen).toEqual(partial)
}) })
@@ -350,7 +350,7 @@ describe('installLlmReplay (through the real waterfall)', () => {
const overrideFile = join(dir, 'replay.override.json') const overrideFile = join(dir, 'replay.override.json')
const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }]
writeFileSync(overrideFile, JSON.stringify([ writeFileSync(overrideFile, JSON.stringify([
{ kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' },
]), 'utf8') ]), 'utf8')
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)