Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
41
packages/llm/llm/README.md
Normal file
41
packages/llm/llm/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# dsh-llm
|
||||
|
||||
Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
|
||||
|
||||
## Service: `LlmService` (ctx key: `llm`)
|
||||
|
||||
An adapter registry plus a single streaming call surface, interceptable via a waterfall event.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
|
||||
- `ctx.llm.models(): string[]` — model names with a registered adapter.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### Classes
|
||||
|
||||
- `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.
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
|
||||
32
packages/llm/llm/package.json
Normal file
32
packages/llm/llm/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-llm",
|
||||
"description": "Provider-neutral LLM service interface for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
143
packages/llm/llm/src/assembler.ts
Normal file
143
packages/llm/llm/src/assembler.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Incremental chunk-to-message assembler. This is the single canonical assembly
|
||||
* algorithm used by the agent loop to build an assistant message from a chunk
|
||||
* stream while logging the raw chunks for replay fidelity.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/assembler
|
||||
*/
|
||||
|
||||
import { CallId } from './brand.ts'
|
||||
import { assertNever } from './never.ts'
|
||||
import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types'
|
||||
|
||||
interface PartialBlock {
|
||||
blockType: string
|
||||
text: string
|
||||
toolCallId?: CallId
|
||||
toolCallName?: string
|
||||
toolCallArguments: string
|
||||
/** Set by `block-end` — authoritative, and freezes the partial. */
|
||||
block?: ContentBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally assembles raw {@link StreamChunk}s into complete
|
||||
* {@link ContentBlock}s and a final assistant {@link Message}.
|
||||
*
|
||||
* The agent loop feeds it while logging raw chunks for replay fidelity, then
|
||||
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
|
||||
*
|
||||
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
|
||||
* an index already closed by `block-end` are ignored (malformed stream) so a
|
||||
* misbehaving adapter cannot grow memory or corrupt a completed block.
|
||||
*/
|
||||
export class BlockAssembler {
|
||||
private partials = new Map<number, PartialBlock>()
|
||||
private order: number[] = []
|
||||
private _usage: TokenUsage | undefined
|
||||
private _finish: FinishReason | undefined
|
||||
|
||||
/**
|
||||
* Feed one chunk. Returns the completed block when the chunk closes one
|
||||
* (an explicit `block-end`), otherwise undefined.
|
||||
*/
|
||||
push(chunk: StreamChunk): ContentBlock | undefined {
|
||||
switch (chunk.type) {
|
||||
case 'block-start': {
|
||||
if (!this.partials.has(chunk.index)) {
|
||||
this.order.push(chunk.index)
|
||||
this.partials.set(chunk.index, {
|
||||
blockType: chunk.blockType,
|
||||
text: '',
|
||||
toolCallArguments: '',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta': {
|
||||
const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning')
|
||||
if (partial.block) return // closed by block-end; ignore stragglers
|
||||
partial.text += chunk.text
|
||||
return
|
||||
}
|
||||
case 'tool-call-delta': {
|
||||
const partial = this.ensure(chunk.index, 'tool-call')
|
||||
if (partial.block) return // closed by block-end; ignore stragglers
|
||||
partial.toolCallId = chunk.id
|
||||
if (chunk.name) partial.toolCallName = chunk.name
|
||||
partial.toolCallArguments += chunk.argumentsDelta
|
||||
return
|
||||
}
|
||||
case 'block-end': {
|
||||
const partial = this.ensure(chunk.index, chunk.block.type)
|
||||
// First close wins: a second block-end for an already-closed index is
|
||||
// a straggler (same rule as post-close deltas). Ignoring it keeps the
|
||||
// streamed prefix and the final blocks() in agreement — otherwise a
|
||||
// re-close could rewrite a block already flushed downstream.
|
||||
if (partial.block) return
|
||||
partial.block = chunk.block
|
||||
return chunk.block
|
||||
}
|
||||
case 'usage': {
|
||||
this._usage = chunk.usage
|
||||
return
|
||||
}
|
||||
case 'finish': {
|
||||
this._finish = chunk.reason
|
||||
return
|
||||
}
|
||||
default: return assertNever(chunk, 'BlockAssembler.push')
|
||||
}
|
||||
}
|
||||
|
||||
private ensure(index: number, blockType: string): PartialBlock {
|
||||
let partial = this.partials.get(index)
|
||||
if (!partial) {
|
||||
partial = { blockType, text: '', toolCallArguments: '' }
|
||||
this.partials.set(index, partial)
|
||||
this.order.push(index)
|
||||
}
|
||||
return partial
|
||||
}
|
||||
|
||||
private assemble(partial: PartialBlock, index: number): ContentBlock {
|
||||
if (partial.block) return partial.block
|
||||
switch (partial.blockType) {
|
||||
case 'text': return { type: 'text', text: partial.text }
|
||||
case 'reasoning': return { type: 'reasoning', text: partial.text }
|
||||
case 'tool-call': return {
|
||||
type: 'tool-call',
|
||||
id: partial.toolCallId ?? CallId(`call-${index}`),
|
||||
name: partial.toolCallName ?? '',
|
||||
arguments: partial.toolCallArguments,
|
||||
}
|
||||
default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Invariant accessor: every index in `order` has a partial. */
|
||||
private mustGet(index: number): PartialBlock {
|
||||
const partial = this.partials.get(index)
|
||||
if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
|
||||
return partial
|
||||
}
|
||||
|
||||
/** Assemble all blocks seen so far, in stream order. */
|
||||
blocks(): ContentBlock[] {
|
||||
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
}
|
||||
|
||||
get usage(): TokenUsage | undefined {
|
||||
return this._usage
|
||||
}
|
||||
|
||||
get finish(): FinishReason {
|
||||
return this._finish ?? { kind: 'stop' }
|
||||
}
|
||||
|
||||
/** The assembled assistant message. */
|
||||
message(): Message {
|
||||
return { role: 'assistant', content: this.blocks() }
|
||||
}
|
||||
}
|
||||
23
packages/llm/llm/src/brand.ts
Normal file
23
packages/llm/llm/src/brand.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* dsh-llm's owned branded id: `CallId` (tool-call correlation).
|
||||
*
|
||||
* The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
|
||||
* zero-dependency type-only package) so every owner of a cross-boundary id can
|
||||
* brand it without depending on dsh-llm; see that package's README for the
|
||||
* nominal-typing policy.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Correlates a model-issued tool call with its result. Provider-issued for
|
||||
* real adapters; synthesized by mocks/assembler fallbacks.
|
||||
*/
|
||||
export type CallId = Branded<'CallId'>
|
||||
|
||||
/** Brand a string as a {@link CallId}. */
|
||||
export function CallId(id: string): CallId {
|
||||
return id as CallId
|
||||
}
|
||||
33
packages/llm/llm/src/error.ts
Normal file
33
packages/llm/llm/src/error.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The harness error taxonomy: one base class so failures carry a stable,
|
||||
* machine-routable `code` and chain their `cause`, instead of flattening to a
|
||||
* bare message string. Per-package errors extend {@link HarnessError}; the
|
||||
* tool layer surfaces `{ name, code }` on results and the session `tool/result`
|
||||
* event so retry/sandbox plugins and replay can distinguish failure classes.
|
||||
*
|
||||
* Lives in dsh-llm (the leaf package every other imports) so a single base is
|
||||
* shared without a new dependency edge. See the error-taxonomy RFC.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/error
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for all harness errors. Carries a `code` (stable, programmatic —
|
||||
* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the
|
||||
* human-readable `message`, and supports `cause` chaining via the standard
|
||||
* `ErrorOptions`. `name` defaults to the subclass constructor name.
|
||||
*/
|
||||
export class HarnessError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.code = code
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). */
|
||||
export function isHarnessError(value: unknown): value is HarnessError {
|
||||
return value instanceof HarnessError
|
||||
}
|
||||
121
packages/llm/llm/src/index.ts
Normal file
121
packages/llm/llm/src/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* LLM service: adapter registry with a waterfall-interceptable streaming call
|
||||
* surface. Exports the `LlmService` default, the abstract `LlmAdapter` for
|
||||
* provider backends, and `BlockAssembler` for chunk assembly.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { GenerateOptions, StreamChunk } from './types'
|
||||
import { HarnessError } from './error'
|
||||
|
||||
export * from './brand'
|
||||
export * from './never'
|
||||
export * from './error'
|
||||
export * from './types'
|
||||
export { BlockAssembler } from './assembler'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
llm: LlmService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall around every streaming model call (retry, caching, routing).
|
||||
* Bound to the {@link LlmService}; call `next()` to reach the resolved
|
||||
* adapter's stream, or yield your own chunks to short-circuit.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export class LlmError extends HarnessError {
|
||||
constructor(message: string, code: string, public status?: number, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'LlmError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for LLM provider adapters.
|
||||
*
|
||||
* An adapter translates between the harness vocabulary (Message/ContentBlock/
|
||||
* StreamChunk) and one provider's wire format. Adapters register themselves
|
||||
* via `ctx.llm.registerAdapter(models, adapter)`.
|
||||
*
|
||||
* Real implementations: `@deepseek-ai/dsh-llm-deepseek` (hand-rolled
|
||||
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
|
||||
* deliberately different internals over the same contract; see the
|
||||
* adapter contract documented on `StreamChunk` in `./types.ts`.
|
||||
*/
|
||||
export abstract class LlmAdapter {
|
||||
/** Stream one model call as raw chunks. The only required method. */
|
||||
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
}
|
||||
|
||||
/**
|
||||
* The abstract `llm` service: an adapter registry plus a streaming model-call
|
||||
* surface, interceptable via the `llm/stream` waterfall.
|
||||
*/
|
||||
export class LlmService extends Service {
|
||||
private adapters = new Map<string, LlmAdapter>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an adapter for the given model names. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
|
||||
* Disposed with the fiber.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
for (const model of models) {
|
||||
if (this.adapters.has(model)) {
|
||||
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
}
|
||||
for (const model of models) this.adapters.set(model, adapter)
|
||||
yield () => {
|
||||
for (const model of models) this.adapters.delete(model)
|
||||
}
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Model names with a registered adapter. */
|
||||
models(): string[] {
|
||||
return [...this.adapters.keys()]
|
||||
}
|
||||
|
||||
private adapter(model: string): LlmAdapter {
|
||||
const adapter = this.adapters.get(model)
|
||||
if (!adapter) throw new LlmError(`no adapter registered for model "${model}"`, 'NO_ADAPTER')
|
||||
return adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.model`. Dispatches through the `llm/stream` waterfall.
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.ctx.waterfall(this, 'llm/stream', options, () => {
|
||||
return this.adapter(options.model).stream(options)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default LlmService
|
||||
35
packages/llm/llm/src/never.ts
Normal file
35
packages/llm/llm/src/never.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Exhaustiveness helper for switches over core unions.
|
||||
*
|
||||
* # When to use which pattern
|
||||
*
|
||||
* **Closed unions** (every variant is known at compile time in the consuming
|
||||
* code — e.g. `StreamChunk` inside the assembler, `FiberState`-like enums):
|
||||
* end the switch with `default: assertNever(value)`. Adding a variant then
|
||||
* fails compilation at every switch that must handle it — the error appears
|
||||
* exactly where work is needed.
|
||||
*
|
||||
* **Merge-extensible unions** (plugins add variants via declaration merging —
|
||||
* `SessionEventMap`, `ContentBlockMap`, `MessageSourceMap`, …): do NOT use
|
||||
* assertNever. From the core's view the union is open; plugin-added variants
|
||||
* are valid values the core has never heard of. Handle the known cases and
|
||||
* fall through intentionally, with a comment saying the switch is
|
||||
* deliberately non-exhaustive (see `Session.deriveMessages`). The lint rule
|
||||
* `switch-exhaustiveness-check` enforces that the choice is explicit either
|
||||
* way.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/never
|
||||
*/
|
||||
|
||||
/**
|
||||
* Marks unreachable code on a closed union. If this is reachable, either a
|
||||
* variant was added without updating the switch (compile error at the call
|
||||
* site — the desired outcome) or a value escaped its type (runtime throw
|
||||
* with diagnostics — the safety net).
|
||||
*/
|
||||
export function assertNever(value: never, context?: string): never {
|
||||
// JSON.stringify is typed string but returns undefined for undefined input;
|
||||
// String() covers that and other non-serializable escapes.
|
||||
const rendered = (JSON.stringify(value) as string | undefined) ?? String(value)
|
||||
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
|
||||
}
|
||||
195
packages/llm/llm/src/types.ts
Normal file
195
packages/llm/llm/src/types.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Provider-neutral message and streaming vocabulary.
|
||||
*
|
||||
* This is the canonical language spoken by the agent loop, session logs, and
|
||||
* every plugin. Adapters translate it to provider wire formats (DeepSeek V4
|
||||
* first); nothing outside an adapter should ever see a provider-specific
|
||||
* shape.
|
||||
*
|
||||
* Extensibility: the unions in this file are derived from interfaces
|
||||
* (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`) so that plugins
|
||||
* can extend them via declaration merging:
|
||||
*
|
||||
* ```ts
|
||||
* declare module '@deepseek-ai/dsh-llm' {
|
||||
* interface ContentBlockMap {
|
||||
* video: { type: 'video'; url: string }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { CallId } from './brand'
|
||||
|
||||
/** Cache hint attached to a content block (provider-interpreted). */
|
||||
export type CacheHint = 'ephemeral'
|
||||
|
||||
/** Plain text visible to the end user. */
|
||||
export interface TextBlock {
|
||||
type: 'text'
|
||||
text: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** Reasoning / thinking content, distinct from visible text. */
|
||||
export interface ReasoningBlock {
|
||||
type: 'reasoning'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** A tool invocation requested by the model. */
|
||||
export interface ToolCallBlock {
|
||||
type: 'tool-call'
|
||||
/** Provider-issued call id; correlates with the matching tool result. */
|
||||
id: CallId
|
||||
name: string
|
||||
/** Raw JSON string as produced by the model. */
|
||||
arguments: string
|
||||
}
|
||||
|
||||
/** The result of a tool invocation, sent back to the model. */
|
||||
export interface ToolResultBlock {
|
||||
type: 'tool-result'
|
||||
toolCallId: CallId
|
||||
content: ContentBlock[]
|
||||
isError?: boolean
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** An image, by URL or data URL. */
|
||||
export interface ImageBlock {
|
||||
type: 'image'
|
||||
url: string
|
||||
mimeType?: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/**
|
||||
* All known content block shapes, keyed by their `type` tag.
|
||||
* Merge-extensible: plugins add new block types via declaration merging.
|
||||
*/
|
||||
export interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
|
||||
export type ContentBlockType = keyof ContentBlockMap
|
||||
export type ContentBlock = ContentBlockMap[ContentBlockType]
|
||||
|
||||
/** A single message in a conversation history. */
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: ContentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a message (or injected content) came from.
|
||||
* Merge-extensible sum type — plugins add their own `kind`s.
|
||||
*/
|
||||
export interface MessageSourceMap {
|
||||
user: { kind: 'user' }
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
agent: { kind: 'agent'; agentId: string }
|
||||
}
|
||||
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
|
||||
/**
|
||||
* Why a model response stopped.
|
||||
* Merge-extensible so adapters can surface provider-specific reasons.
|
||||
*/
|
||||
export interface FinishReasonMap {
|
||||
'stop': { kind: 'stop' }
|
||||
'tool-calls': { kind: 'tool-calls' }
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
'aborted': { kind: 'aborted' }
|
||||
'error': { kind: 'error'; message: string; code?: string }
|
||||
}
|
||||
|
||||
export type FinishReason = FinishReasonMap[keyof FinishReasonMap]
|
||||
|
||||
/**
|
||||
* Token accounting for one model call (cache fields are optional).
|
||||
*
|
||||
* Counts are DISJOINT: `inputTokens` is uncached input only; cached input is
|
||||
* reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input =
|
||||
* sum of the three). Adapters whose providers fold cache hits into a total
|
||||
* prompt count (DeepSeek's `prompt_tokens`) subtract them out.
|
||||
*/
|
||||
export interface TokenUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
*
|
||||
* A streaming response interleaves several typed blocks (text, reasoning,
|
||||
* multiple tool calls); `index` ties each delta to its block, and `block-end`
|
||||
* carries the fully-assembled ContentBlock so consumers don't have to
|
||||
* re-assemble deltas themselves (use {@link BlockAssembler} when they do).
|
||||
*
|
||||
* Adapter contract — every adapter MUST obey these, and every consumer may
|
||||
* rely on them:
|
||||
* - Emit `usage` BEFORE `finish`, and nothing after `finish` (defer both to
|
||||
* the provider's end-of-stream marker so trailing usage-only chunks can't
|
||||
* violate this).
|
||||
* - Tool-call `arguments` stay RAW JSON strings end-to-end; partial fragments
|
||||
* stream via `argumentsDelta` (providers that hand back parsed objects
|
||||
* re-stringify at `block-end`).
|
||||
* - Failures may either THROW from `stream()` (transport/protocol errors) or
|
||||
* end the stream with `finish {kind:'error'|'aborted'}` (provider in-band
|
||||
* errors, for adapters that can't throw mid-stream); consumers must handle
|
||||
* both. The agent loop translates a finish-error/aborted into a turn error —
|
||||
* it never logs a normal completed assistant message for a failed step.
|
||||
*/
|
||||
export type StreamChunk =
|
||||
| { type: 'block-start'; index: number; blockType: ContentBlockType }
|
||||
| { type: 'text-delta'; index: number; text: string }
|
||||
| { type: 'reasoning-delta'; index: number; text: string }
|
||||
| { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
|
||||
| { type: 'block-end'; index: number; block: ContentBlock }
|
||||
| { type: 'usage'; usage: TokenUsage }
|
||||
| { type: 'finish'; reason: FinishReason }
|
||||
|
||||
/**
|
||||
* JSON-schema description of a tool, as sent to the model.
|
||||
*
|
||||
* Declared here (not in dsh-tools) because it is part of {@link GenerateOptions};
|
||||
* dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import
|
||||
* it from this package.
|
||||
*/
|
||||
export interface ToolSchema {
|
||||
name: string
|
||||
description: string
|
||||
/** JSON Schema object for the arguments. */
|
||||
parameters: Record<string, unknown>
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/** A single model request, fully assembled. */
|
||||
export interface GenerateOptions {
|
||||
model: string
|
||||
messages: Message[]
|
||||
/** System prompt text (adapters map to the provider's system slot). */
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Assistant prefix continuation (prefill). */
|
||||
prefill?: ContentBlock[]
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
* Stop sequences: generation halts as soon as the model produces any one of
|
||||
* these strings (adapters map to the provider's stop field, e.g. OpenAI
|
||||
* `stop`). The stop string itself is not included in the output.
|
||||
*/
|
||||
stop?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
171
packages/llm/llm/tests/assembler.spec.ts
Normal file
171
packages/llm/llm/tests/assembler.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockAssembler, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
describe('BlockAssembler', () => {
|
||||
it('assembles interleaved text, reasoning, and tool-call deltas', () => {
|
||||
const chunks: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'thinking…' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking…' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 1, text: 'Hello' },
|
||||
{ type: 'text-delta', index: 1, text: ' world' },
|
||||
{ type: 'block-start', index: 2, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 2, id: CallId('call-1'), name: 'echo', argumentsDelta: '{"text":' },
|
||||
{ type: 'tool-call-delta', index: 2, id: CallId('call-1'), argumentsDelta: '"hi"}' },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
const assembler = new BlockAssembler()
|
||||
for (const chunk of chunks) assembler.push(chunk)
|
||||
|
||||
expect(assembler.blocks()).toEqual([
|
||||
{ type: 'reasoning', text: 'thinking…' },
|
||||
{ type: 'text', text: 'Hello world' },
|
||||
{ type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{"text":"hi"}' },
|
||||
])
|
||||
expect(assembler.usage).toEqual({ inputTokens: 10, outputTokens: 5 })
|
||||
expect(assembler.finish).toEqual({ kind: 'tool-calls' })
|
||||
expect(assembler.message().role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('returns the completed block from push() on 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' })
|
||||
})
|
||||
|
||||
it('tolerates deltas without explicit block-start/end', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'implicit' })
|
||||
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'implicit' }])
|
||||
expect(assembler.finish).toEqual({ kind: 'stop' })
|
||||
})
|
||||
|
||||
it('returns undefined usage when no usage chunk was received', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'no usage' })
|
||||
expect(assembler.usage).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses an existing partial when ensure() is called with a tracked index', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// block-start creates the partial; block-end calls ensure() on the same index
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
||||
// 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' })
|
||||
})
|
||||
|
||||
it('throws from assemble() when a partial has an unhandled blockType', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// Directly push a block-end for an image block whose block-start never
|
||||
// called ensure — but the image block-type flows through normally.
|
||||
// What we really need is a partial whose blockType is not text/reasoning/tool-call.
|
||||
// We can achieve this via a block-start for 'image' followed by blocks().
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
|
||||
})
|
||||
|
||||
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// Force the invariant violation: manually corrupt the data structures.
|
||||
/* eslint-disable */
|
||||
const hack = assembler as any
|
||||
hack.order.push(99)
|
||||
/* eslint-enable */
|
||||
expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
|
||||
})
|
||||
|
||||
it('ignores duplicate block-start for the same index', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'one' })
|
||||
// duplicate block-start — should be no-op (false branch of has check)
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
||||
assembler.push({ type: 'text-delta', index: 0, text: ' two' })
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one two' } })
|
||||
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'one two' }])
|
||||
})
|
||||
|
||||
it('ignores tool-call-delta stragglers after block-end', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'tool-call' })
|
||||
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'echo', argumentsDelta: '{}' })
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' } })
|
||||
// straggler after block-end — partial.block is set, so early return
|
||||
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'evil', argumentsDelta: 'oops' })
|
||||
expect(assembler.blocks()).toEqual([{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }])
|
||||
})
|
||||
|
||||
it('assembles tool-call with generated id fallback when no id provided', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'tool-call-delta', index: 0, argumentsDelta: '{}' } as StreamChunk)
|
||||
// No id and no name provided — uses fallback id `call-{index}` and empty name
|
||||
const blocks = assembler.blocks()
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'tool-call', id: CallId('call-0'), name: '', arguments: '{}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('exposes usage via the getter when a usage chunk was received', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
|
||||
assembler.push({ type: 'usage', usage: { inputTokens: 5, outputTokens: 3 } })
|
||||
expect(assembler.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertNever', () => {
|
||||
it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
|
||||
const { assertNever } = await import('@deepseek-ai/dsh-llm')
|
||||
expect(() => assertNever({ type: 'rogue' } as never, 'test-context'))
|
||||
.toThrow('unreachable variant in test-context: {"type":"rogue"}')
|
||||
expect(() => assertNever(undefined as never)).toThrow('unreachable variant: undefined')
|
||||
})
|
||||
|
||||
it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk))
|
||||
.toThrow('unreachable variant in BlockAssembler.push')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlockAssembler regressions (property-test findings)', () => {
|
||||
it('first block-end wins: a duplicate block-end for a closed index is ignored', () => {
|
||||
// Found by fast-check (the property-testing RFC): two block-ends at the same index made the
|
||||
// streamed prefix (first block) disagree with final blocks() (second
|
||||
// block). The first close must win — same straggler rule as post-close
|
||||
// deltas — so the prefix returned incrementally by push() and the final
|
||||
// blocks() stay identical.
|
||||
const chunks: StreamChunk[] = [
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
102
packages/llm/llm/tests/properties.spec.ts
Normal file
102
packages/llm/llm/tests/properties.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Property-based tests for the BlockAssembler (the property-testing RFC).
|
||||
*
|
||||
* The assembler is protocol-shaped: arbitrary interleavings of block-start,
|
||||
* deltas, block-end, usage, and finish — valid and malformed (duplicate
|
||||
* indices, stragglers after block-end, missing block-start, delta-only). The
|
||||
* invariants below are the contract the agent loop relies on.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
// A small pool of indices so collisions (duplicate-index bugs) are common.
|
||||
const indexArb = fc.integer({ min: 0, max: 4 })
|
||||
|
||||
const blockEndArb = (index: number): fc.Arbitrary<StreamChunk> => fc.oneof(
|
||||
fc.record({ text: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'text', text: r.text } }
|
||||
)),
|
||||
fc.record({ text: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'reasoning', text: r.text } }
|
||||
)),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), name: fc.string(), args: fc.string() }).map((r): StreamChunk => (
|
||||
{ type: 'block-end', index, block: { type: 'tool-call', id: CallId(r.id), name: r.name, arguments: r.args } }
|
||||
)),
|
||||
)
|
||||
|
||||
/** One arbitrary chunk over the small index pool — valid and malformed mixes. */
|
||||
const chunkArb: fc.Arbitrary<StreamChunk> = indexArb.chain(index => fc.oneof(
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'text' }),
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'reasoning' }),
|
||||
fc.constant<StreamChunk>({ type: 'block-start', index, blockType: 'tool-call' }),
|
||||
fc.string().map((text): StreamChunk => ({ type: 'text-delta', index, text })),
|
||||
fc.string().map((text): StreamChunk => ({ type: 'reasoning-delta', index, text })),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), argumentsDelta: fc.string() })
|
||||
.map((r): StreamChunk => ({ type: 'tool-call-delta', index, id: CallId(r.id), argumentsDelta: r.argumentsDelta })),
|
||||
blockEndArb(index),
|
||||
fc.constant<StreamChunk>({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }),
|
||||
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'stop' } }),
|
||||
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'tool-calls' } }),
|
||||
fc.string().map((message): StreamChunk => ({ type: 'finish', reason: { kind: 'error', message } })),
|
||||
))
|
||||
|
||||
/** A stream is an arbitrary list of chunks (we do NOT force a terminal finish). */
|
||||
const streamArb = fc.array(chunkArb, { maxLength: 30 })
|
||||
|
||||
/** Feed a fresh assembler, return it. */
|
||||
function feed(chunks: StreamChunk[]): BlockAssembler {
|
||||
const a = new BlockAssembler()
|
||||
for (const chunk of chunks) a.push(chunk)
|
||||
return a
|
||||
}
|
||||
|
||||
describe('BlockAssembler properties', () => {
|
||||
it('partials map size never exceeds the number of distinct indices seen', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const distinct = new Set<number>()
|
||||
for (const chunk of chunks) {
|
||||
if ('index' in chunk) distinct.add(chunk.index)
|
||||
}
|
||||
const a = feed(chunks)
|
||||
// blocks() length equals the number of distinct indices that became
|
||||
// partials (block-bearing chunks). It can never exceed distinct indices.
|
||||
expect(a.blocks().length).toBeLessThanOrEqual(distinct.size)
|
||||
}))
|
||||
})
|
||||
|
||||
it('re-assembly is idempotent: blocks() is stable across repeated calls', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const a = feed(chunks)
|
||||
expect(a.blocks()).toEqual(a.blocks())
|
||||
// And message().content mirrors blocks().
|
||||
expect(a.message().content).toEqual(a.blocks())
|
||||
}))
|
||||
})
|
||||
|
||||
it('blocks() never throws and yields only valid content-block tags', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const blocks = feed(chunks).blocks()
|
||||
for (const block of blocks) {
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('finish reflects the last finish chunk, or defaults to stop when none arrives', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const a = feed(chunks)
|
||||
const finishes = chunks.filter(c => c.type === 'finish')
|
||||
if (finishes.length === 0) {
|
||||
expect(a.finish).toEqual({ kind: 'stop' })
|
||||
} else {
|
||||
// last-write-wins: the assembler keeps the most recent finish reason.
|
||||
const last = finishes[finishes.length - 1]
|
||||
if (last?.type === 'finish') expect(a.finish).toEqual(last.reason)
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
141
packages/llm/llm/tests/service.spec.ts
Normal file
141
packages/llm/llm/tests/service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: StreamChunk[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
describe('LlmService', () => {
|
||||
it('routes stream() to the registered adapter', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toEqual(SCRIPT)
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect((async () => {
|
||||
for await (const _ of ctx.llm.stream({ model: 'nope', messages: [] })) { /* drain */ }
|
||||
})()).rejects.toThrow('no adapter registered')
|
||||
})
|
||||
|
||||
it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT))
|
||||
}, { inject: ['llm'] }))
|
||||
expect(ctx.llm.models()).toEqual(['scoped-model'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('lets llm/stream waterfall listeners wrap the underlying stream', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
ctx.on('llm/stream', function (_options, next) {
|
||||
const inner = next()
|
||||
return (async function * () {
|
||||
yield { type: 'block-start', index: 99, blockType: 'text' } satisfies StreamChunk
|
||||
yield * inner
|
||||
})()
|
||||
})
|
||||
|
||||
const chunks: StreamChunk[] = []
|
||||
for await (const chunk of ctx.llm.stream({ model: 'test-model', messages: [] })) chunks.push(chunk)
|
||||
expect(chunks).toHaveLength(4)
|
||||
expect(chunks[0]).toMatchObject({ index: 99 })
|
||||
})
|
||||
|
||||
it('creates LlmError with a code for programmatic handling', () => {
|
||||
const err = new LlmError('something went wrong', 'CUSTOM_CODE')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('LlmError')
|
||||
expect(err.message).toBe('something went wrong')
|
||||
expect(err.code).toBe('CUSTOM_CODE')
|
||||
})
|
||||
|
||||
it('LlmError extends the shared HarnessError base', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new LlmError('boom', 'AUTH', 401)
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(err.code).toBe('AUTH')
|
||||
expect(err.status).toBe(401)
|
||||
})
|
||||
|
||||
it('HarnessError carries a code, names itself by subclass, and chains cause', async () => {
|
||||
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const root = new Error('root cause')
|
||||
const err = new HarnessError('wrapper', 'UNKNOWN', { cause: root })
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('HarnessError')
|
||||
expect(err.code).toBe('UNKNOWN')
|
||||
expect(err.cause).toBe(root)
|
||||
expect(isHarnessError(err)).toBe(true)
|
||||
expect(isHarnessError(root)).toBe(false)
|
||||
expect(isHarnessError('nope')).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the adapter when the returned disposer is called', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate adapter registration with DUPLICATE_ADAPTER code', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
try {
|
||||
ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect.fail('expected error')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(LlmError)
|
||||
expect((error as LlmError).message).toContain('already registered')
|
||||
expect((error as LlmError).code).toBe('DUPLICATE_ADAPTER')
|
||||
}
|
||||
})
|
||||
|
||||
it('re-registers a model after its prior registration is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
|
||||
// The duplicate check is not wedged: the same model registers cleanly again.
|
||||
const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
disposeAgain()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
})
|
||||
21
packages/llm/llm/tsconfig.json
Normal file
21
packages/llm/llm/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user