diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4cac932a6f..53e5142dff 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -394,7 +394,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b858cb6260..e384bf3ad0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -144,7 +144,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:96`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:94`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 67404ab49a..db7a9c8a79 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -20,7 +20,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe ## Consequences - Generator quality is the value lever — the generators bias toward small index pools and short strings so collisions and interleavings are common. -- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. +- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index rewrote a completed block. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. - A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect. - Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate. diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 5f05c92317..238f2044ad 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -28,7 +28,7 @@ Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` ``` { kind: 'chunks', chunks: StreamChunk[] } -| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string } | { kind: 'hang' } ``` diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 45348cfd2e..5c9e1014b8 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -6,7 +6,7 @@ Status: implemented Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Decision diff --git a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json index eea32f25ca..cfa0d84227 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json +++ b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json @@ -1,3 +1,3 @@ [ - { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH", "status": 401 } + { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" } ] diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 09011c77de..2ddee8dbca 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -175,9 +175,13 @@ describe('cordis_mount', () => { // The registered schema is canonical JSON Schema derived from the DSL: // the required array survived, integer became number, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! - const parameters = schema.parameters as { properties: Record; required?: string[] } + const parameters = schema.parameters as { + properties: Record + required?: string[] + } expect(parameters.required).toEqual(['text']) expect(parameters.properties.count!.type).toBe('number') + expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index cfe2fdf2fa..bfd75fdcb6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -154,7 +154,7 @@ The available tools: - **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own. - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). -- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input. +- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. - **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index c61c819618..f2b62669b1 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -21,12 +21,8 @@ export interface SchemaProp { /** Enum of allowed values (strings only). */ enum?: string[] /** - * Default value, emitted into the JSON Schema only (validation never applies - * it — see the validator note below). - * - * XXX(unused-default): no tool definition in the repo sets `default`; it rides - * into the wire schema for a model that no tool surfaces it to. Drop the field - * and its converter line unless a real tool needs a model-visible default. + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. */ default?: unknown /** Nested properties for type: 'object'. */ diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7da98cdebf..a4b43342e4 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -4,6 +4,8 @@ DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. +The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract. + ## Config ```yaml diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ece053c6b3..5fa01ce503 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -98,10 +98,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/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 695831b1bd..f9f223b6ff 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -11,12 +11,9 @@ import type {} from '@deepseek-ai/dsh-llm' import { DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' -export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' +export { DeepSeekAdapter } from './adapter.ts' export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' -export { serializeMessages, serializeRequest } from './serialize.ts' export type { RequestDefaults } from './serialize.ts' -export { DONE, parseSse } from './sse.ts' -export { mapFinishReason, mapUsage, translate } from './translate.ts' export type * from './types.ts' export const name = 'llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index c0a2f8f482..77a8f1d8a4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ @@ -159,7 +160,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}`) @@ -167,11 +168,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 () => { @@ -241,6 +237,19 @@ describe('DeepSeekAdapter against a mock server', () => { }) describe('plugin registration and config', () => { + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { const server = await mockServer([]) const ctx = new Context() diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 0a5d10e23b..8d3b190ed1 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' +import { serializeMessages, serializeRequest } from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides } diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 2fc297bbec..b18862e4f3 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE, parseSse } from '../src/sse.ts' /** Build a byte stream from string fragments (fragments = network reads). */ async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index d6968faed5..e62cebc4af 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE } from '../src/sse.ts' +import { mapFinishReason, mapUsage, translate } from '../src/translate.ts' async function* feed(...payloads: (string | object)[]): AsyncGenerator { for (const payload of payloads) { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bb63d82010..cb2abc8205 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,6 +2,8 @@ Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. + ## Config Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. @@ -76,4 +78,4 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. -- **`LlmError.status` is unavailable for in-stream failures** — pi-ai error events do not expose a stable HTTP status across providers. +- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index cbd0dcd435..ab08f21b81 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -27,12 +27,8 @@ import { Config, resolveProfiles } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' -export { Config, resolveProfiles } from './config.ts' +export { Config } from './config.ts' export type { PiAiProviderProfile } from './config.ts' -export { toPiContext } from './context.ts' -export { toPiReplayState } from './replay.ts' -export type { PiAiReplayState } from './replay.ts' -export { mapStopReason, mapUsage, toStreamChunks } from './stream.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1767447d7d..559c36bf27 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' -import { PiAiAdapter, resolveProfiles } from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' interface MockServer { @@ -180,6 +181,18 @@ describe('PiAiAdapter provider routing', () => { }) describe('provider profile lifecycle', () => { + it('keeps adapter helpers off the package root', () => { + for (const helper of [ + 'resolveProfiles', + 'toPiContext', + 'toPiReplayState', + 'toPiAssistant', + 'mapStopReason', + 'mapUsage', + 'toStreamChunks', + ]) expect(LlmPiAi).not.toHaveProperty(helper) + }) + it('registers every profile atomically and unregisters on dispose', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 50955f0607..2b405c7c91 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { CallId, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' -import { mapStopReason, mapUsage, toPiContext, toPiReplayState, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' +import { toPiContext } from '../src/context.ts' +import { toPiReplayState } from '../src/replay.ts' +import { mapStopReason, mapUsage, toStreamChunks } from '../src/stream.ts' function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage { return { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index a67859ed19..039844040e 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -45,7 +45,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/assembler.ts b/packages/llm/llm/src/assembler.ts index 8780718c7a..a721721fb6 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -39,12 +39,10 @@ export class BlockAssembler { private _replayState: unknown = undefined /** - * Feed one chunk. Returns the completed block when the chunk closes one - * (an explicit `block-end`), otherwise undefined. + * Feed one chunk into the assembly state. * @param chunk - the next raw chunk, in stream order. - * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk. */ - push(chunk: StreamChunk): ContentBlock | undefined { + push(chunk: StreamChunk): void { switch (chunk.type) { case 'block-start': { if (!this.partials.has(chunk.index)) { @@ -78,7 +76,7 @@ export class BlockAssembler { // and the final assembled block in agreement. if (partial.block) return partial.block = chunk.block - return chunk.block + return } case 'usage': { this._usage = chunk.usage diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 26e9e3b289..020012a1da 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -43,12 +43,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/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 5612e93cb4..f7a5028d14 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -29,12 +29,12 @@ describe('BlockAssembler', () => { expect(assembler.message().role).toBe('assistant') }) - it('returns the completed block from push() on block-end', () => { + it('records the completed block from block-end', () => { const assembler = new BlockAssembler() - expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined() - expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined() - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('tolerates deltas without explicit block-start/end', () => { @@ -57,8 +57,8 @@ describe('BlockAssembler', () => { // push a delta first to guarantee the partial exists assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) // block-end's ensure() must find the existing partial (the second branch path) - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('throws from assemble() when a partial has an unhandled blockType', () => { @@ -128,7 +128,7 @@ describe('assertNever', () => { it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => { const assembler = new BlockAssembler() - expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk)) + expect(() => { assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk) }) .toThrow('unreachable variant in BlockAssembler.push') }) }) @@ -140,26 +140,8 @@ describe('BlockAssembler duplicate-close contract', () => { { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, ] - const streaming = new BlockAssembler() - const closed = [] - for (const chunk of chunks) { - const block = streaming.push(chunk) - if (block) closed.push(block) - } - - const oneShot = new BlockAssembler() - for (const chunk of chunks) oneShot.push(chunk) - - expect(closed).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(closed).toEqual(oneShot.blocks()) - }) - - it('push returns undefined for a duplicate block-end (it closed nothing)', () => { - const a = new BlockAssembler() - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } })) - .toEqual({ type: 'text', text: 'x' }) - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } })) - .toBeUndefined() + const assembler = new BlockAssembler() + for (const chunk of chunks) assembler.push(chunk) + expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 83429ba0cd..c9a4d2936b 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -255,11 +255,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 51e799114f..3299905a07 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmAdapter, 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' } /** One model exposed by a replay-only provider catalog. */ @@ -283,7 +283,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 c9bd545b36..59ffc485db 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) }) @@ -265,12 +265,12 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', 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) @@ -279,7 +279,7 @@ describe('installLlmReplay (through the real LlmService)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) expect(seen).toEqual(partial) }) @@ -384,7 +384,7 @@ describe('installLlmReplay (through the real LlmService)', () => { 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) diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index 200e926d15..a70d5bd478 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L96) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L94) ### ctx.llm.registerAdapter(providers, adapter) @@ -21,7 +21,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L109) ### ctx.llm.listProviders() @@ -33,7 +33,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L140) ### ctx.llm.listModels(provider) @@ -47,7 +47,7 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L150) ### ctx.llm.stream(options) @@ -61,4 +61,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L210) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L208)