Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
@@ -1,46 +1,11 @@
|
||||
# dsh-llm
|
||||
# llm/ — LLM capability family
|
||||
|
||||
Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
|
||||
The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages.
|
||||
|
||||
## Service: `LlmService` (ctx key: `llm`)
|
||||
|
||||
An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events.
|
||||
|
||||
### 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).
|
||||
- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>` Stream as completed content blocks (convenience view).
|
||||
- `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>` One model call, fully assembled.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
|
||||
| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call |
|
||||
| `llm/adapter-change` | emit | An adapter was registered or unregistered |
|
||||
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
|
||||
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
|
||||
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
|
||||
- Wrap `llm/stream` or `llm/generate` 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. Used by the agent loop (raw chunks for replay
|
||||
+ assembled for history) and by `streamBlocks()`/`generate()`.
|
||||
- `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/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).
|
||||
The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist.
|
||||
|
||||
46
packages/llm/llm-deepseek/README.md
Normal file
46
packages/llm/llm-deepseek/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# @deepseek-ai/dsh-llm-deepseek
|
||||
|
||||
DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` + SSE translation from the official wire format (source of truth: the API docs — guides/thinking_mode, guides/tool_calls, api/create-chat-completion) into the `StreamChunk` protocol.
|
||||
|
||||
A second, independent implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one per context (registering both for the same model names throws by design).
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
models: [deepseek-v4-flash, deepseek-v4-pro] # one adapter, registered for each name
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
|
||||
```
|
||||
|
||||
`models` lists every model name this one adapter instance serves: the adapter registers itself for each (the harness model name IS the wire `model` string), so a `generate`/`stream` call routes to it whenever `options.model` is any of them. Registering a second adapter for a name already taken throws `LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per model, all-or-nothing).
|
||||
|
||||
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
|
||||
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
|
||||
|
||||
## Wire-format notes (verified live + against the official docs)
|
||||
|
||||
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
|
||||
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
|
||||
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
|
||||
- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly).
|
||||
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
|
||||
|
||||
## Limitations (MVP, documented deliberately)
|
||||
|
||||
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
|
||||
- `image` blocks are skipped (no vision support on these models).
|
||||
- `tool_choice` is not mapped (not part of the core vocabulary).
|
||||
|
||||
## Errors
|
||||
|
||||
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
|
||||
35
packages/llm/llm-deepseek/package.json
Normal file
35
packages/llm/llm-deepseek/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-llm-deepseek",
|
||||
"description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam",
|
||||
"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-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
101
packages/llm/llm-deepseek/src/adapter.ts
Normal file
101
packages/llm/llm-deepseek/src/adapter.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
|
||||
* chat-completions endpoint, emitting harness StreamChunks.
|
||||
*
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeRequest } from './serialize'
|
||||
import type { RequestDefaults } from './serialize'
|
||||
import { parseSse } from './sse'
|
||||
import { translate } from './translate'
|
||||
import type { WireError } from './types'
|
||||
|
||||
export interface DeepSeekAdapterOptions {
|
||||
apiKey: string
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults?: RequestDefaults
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution header sent on every request so the provider can identify the
|
||||
* client. Bump in lockstep with this package's version (no build-time version
|
||||
* injection is wired in this repo yet).
|
||||
*/
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Map an HTTP status to a stable LlmError code. */
|
||||
export function httpErrorCode(status: number): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
if (status === 429) return 'RATE_LIMIT'
|
||||
if (status === 400) return 'INVALID_REQUEST'
|
||||
if (status >= 500) return 'SERVER'
|
||||
return `HTTP_${status}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The first real `LlmAdapter`. One instance serves every model name it was
|
||||
* registered under (the harness model name IS the wire model name).
|
||||
*
|
||||
* Abort: `options.signal` is handed to fetch — both the initial request and
|
||||
* the body stream reject on abort, which surfaces to the loop as a rejected
|
||||
* step (the loop already contains step errors).
|
||||
*/
|
||||
export class DeepSeekAdapter extends LlmAdapter {
|
||||
constructor(private readonly options: DeepSeekAdapterOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, this.options.defaults ?? {})
|
||||
|
||||
// TODO(http): deliberately raw `fetch` for the hand-rolled SSE body.
|
||||
// `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout
|
||||
// uniformity AND can stream (`responseType: 'stream'` yields the same
|
||||
// ReadableStream<Uint8Array> parseSse consumes), but adopting it today
|
||||
// costs a hard `undici` dependency (it does `require('undici')` with no
|
||||
// globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file`
|
||||
// import (pulling file-type + mime-types) for a file:// path we never hit.
|
||||
// Revisit when a second adapter wants shared proxy/intercept config.
|
||||
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const code = httpErrorCode(response.status)
|
||||
let message = `DeepSeek API error (HTTP ${response.status})`
|
||||
try {
|
||||
const parsed = await response.json() as WireError
|
||||
if (parsed.error?.message) message = parsed.error.message
|
||||
} catch {
|
||||
// Paranoid by design: `code` and the HTTP status are ALREADY captured
|
||||
// above (and passed to LlmError below), so the only thing this `try`
|
||||
// can add is a richer provider-supplied message. A malformed, empty,
|
||||
// or non-JSON error body is a normal thing for gateways/proxies to
|
||||
// return on a 5xx/429 — swallowing the parse failure keeps the usable
|
||||
// status-line message instead of letting a JSON.parse throw mask the
|
||||
// real HTTP error. Nothing else reaches this catch: response.json()
|
||||
// is the sole statement, and any non-parse failure (e.g. body already
|
||||
// consumed) is equally non-actionable here.
|
||||
}
|
||||
throw new LlmError(message, code, response.status)
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')
|
||||
}
|
||||
|
||||
yield* translate(parseSse(response.body))
|
||||
}
|
||||
}
|
||||
78
packages/llm/llm-deepseek/src/index.ts
Normal file
78
packages/llm/llm-deepseek/src/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the
|
||||
* configured model names on `ctx.llm`.
|
||||
*
|
||||
* Config is cordis-native (schemastery). Secrets flow per the repo policy:
|
||||
* `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`)
|
||||
* or from the environment directly; never from ad-hoc files.
|
||||
*
|
||||
* ```yaml
|
||||
* - id: llm-deepseek
|
||||
* name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
* config:
|
||||
* apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
* models: [deepseek-v4-flash, deepseek-v4-pro]
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-deepseek
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { DeepSeekAdapter } from './adapter'
|
||||
|
||||
export { DeepSeekAdapter, httpErrorCode } from './adapter'
|
||||
export type { DeepSeekAdapterOptions } from './adapter'
|
||||
export { serializeMessages, serializeRequest } from './serialize'
|
||||
export type { RequestDefaults } from './serialize'
|
||||
export { DONE, parseSse } from './sse'
|
||||
export { mapFinishReason, mapUsage, translate } from './translate'
|
||||
export type * from './types'
|
||||
|
||||
export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['high', 'max']),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
|
||||
}
|
||||
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
|
||||
// schemastery's .default() guarantees models is set after validation.
|
||||
const models = config.models as string[]
|
||||
|
||||
ctx.llm.registerAdapter(models, new DeepSeekAdapter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
defaults: {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
}))
|
||||
}
|
||||
144
packages/llm/llm-deepseek/src/serialize.ts
Normal file
144
packages/llm/llm-deepseek/src/serialize.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the
|
||||
* DeepSeek chat-completions request body.
|
||||
*
|
||||
* Block-type mapping (core types handled explicitly; merge-extensible unions
|
||||
* mean plugin-added block types exist — they are skipped, never errors):
|
||||
*
|
||||
* - user `text` → string content (joined)
|
||||
* - assistant `text` → `content`; `reasoning` → `reasoning_content`, but
|
||||
* ONLY on assistant messages that carry tool calls (the official passback
|
||||
* rule for thinking mode — required there, ignored elsewhere, so we save
|
||||
* the tokens elsewhere); `tool-call` → `tool_calls[]`
|
||||
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
|
||||
* - `image` → skipped (MVP limitation, documented in the README)
|
||||
*
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { WireMessage, WireRequest, WireTool } from './types'
|
||||
|
||||
/** Adapter-level request defaults (from plugin config). */
|
||||
export interface RequestDefaults {
|
||||
thinking?: 'enabled' | 'disabled' | undefined
|
||||
reasoningEffort?: 'high' | 'max' | undefined
|
||||
}
|
||||
|
||||
/** Join the text blocks of a message (used for user/tool-result content). */
|
||||
function flattenText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Serialize one assistant message (text + reasoning + tool calls). */
|
||||
function serializeAssistant(message: Message): WireMessage {
|
||||
const text = flattenText(message.content)
|
||||
const reasoning = message.content
|
||||
.filter(block => block.type === 'reasoning')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
const toolCalls = message.content
|
||||
.filter(block => block.type === 'tool-call')
|
||||
.map(block => ({
|
||||
id: block.id,
|
||||
type: 'function' as const,
|
||||
function: { name: block.name, arguments: block.arguments },
|
||||
}))
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
// Tool-call turns send "" rather than null: the live API answers both,
|
||||
// but the official samples replay message.content verbatim (which is ""
|
||||
// for pure tool-call responses) and some gateways reject null outright.
|
||||
content: text.length > 0 ? text : toolCalls.length > 0 ? '' : null,
|
||||
// Official passback rule (guides/thinking_mode.mdx): reasoning_content
|
||||
// must return on tool-call turns; it is ignored on plain turns, so we
|
||||
// drop it there to save tokens.
|
||||
...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {},
|
||||
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the conversation. `tool-result` blocks become standalone
|
||||
* `{role: 'tool'}` messages; the harness puts each tool result in its own
|
||||
* user-role message, so a mixed user message contributes its text first and
|
||||
* its tool results as separate wire messages after.
|
||||
*/
|
||||
export function serializeMessages(messages: Message[]): WireMessage[] {
|
||||
const wire: WireMessage[] = []
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system') {
|
||||
wire.push({ role: 'system', content: flattenText(message.content) })
|
||||
continue
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
wire.push(serializeAssistant(message))
|
||||
continue
|
||||
}
|
||||
// user role: tool results ride in user messages in the harness
|
||||
// vocabulary, but DeepSeek wants them as role:'tool' messages.
|
||||
const toolResults = message.content.filter(block => block.type === 'tool-result')
|
||||
const text = flattenText(message.content)
|
||||
if (text.length > 0 || toolResults.length === 0) {
|
||||
wire.push({ role: 'user', content: text })
|
||||
}
|
||||
for (const result of toolResults) {
|
||||
wire.push({
|
||||
role: 'tool',
|
||||
tool_call_id: result.toolCallId,
|
||||
// Empty tool output still needs SOME content on the wire.
|
||||
content: flattenText(result.content) || '(no output)',
|
||||
})
|
||||
}
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full wire request. Throws `LlmError('UNSUPPORTED')` for
|
||||
* `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a
|
||||
* different base URL — see README).
|
||||
*/
|
||||
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
|
||||
if (options.prefill !== undefined) {
|
||||
throw new LlmError(
|
||||
'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)',
|
||||
'UNSUPPORTED',
|
||||
)
|
||||
}
|
||||
|
||||
const messages: WireMessage[] = []
|
||||
if (options.system !== undefined) {
|
||||
messages.push({ role: 'system', content: options.system })
|
||||
}
|
||||
messages.push(...serializeMessages(options.messages))
|
||||
|
||||
const tools: WireTool[] | undefined = options.tools?.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
// strict is officially supported (Beta); pass the tool author's choice.
|
||||
...tool.strict !== undefined ? { strict: tool.strict } : {},
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
model: options.model,
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...defaults.thinking !== undefined ? { thinking: { type: defaults.thinking } } : {},
|
||||
...defaults.reasoningEffort !== undefined ? { reasoning_effort: defaults.reasoningEffort } : {},
|
||||
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},
|
||||
...options.stop !== undefined ? { stop: options.stop } : {},
|
||||
}
|
||||
}
|
||||
71
packages/llm/llm-deepseek/src/sse.ts
Normal file
71
packages/llm/llm-deepseek/src/sse.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
|
||||
*
|
||||
* Yields each event's `data:` payload as a string, ending with the literal
|
||||
* `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream
|
||||
* that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`.
|
||||
*
|
||||
* Handles the wire realities: payloads split across network reads at
|
||||
* arbitrary byte positions (including mid-UTF-8), CRLF line endings,
|
||||
* multi-`data:` events (joined with newlines per the SSE spec), comment
|
||||
* lines, and non-data fields (ignored).
|
||||
*
|
||||
* @module dsh-llm-deepseek/sse
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */
|
||||
export const DONE = '[DONE]'
|
||||
|
||||
/** Extract the joined data payload from one raw SSE event block. */
|
||||
function eventData(block: string): string | undefined {
|
||||
const data: string[] = []
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
||||
if (line.startsWith('data:')) {
|
||||
// The spec strips ONE leading space after the colon.
|
||||
data.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5))
|
||||
}
|
||||
// Comments (':…') and other fields (event:, id:, retry:) are ignored.
|
||||
}
|
||||
if (data.length === 0) return undefined
|
||||
return data.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final
|
||||
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
||||
* without it (truncated response — the model call cannot be trusted).
|
||||
*/
|
||||
export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
for await (const bytes of stream) {
|
||||
buffer += decoder.decode(bytes, { stream: true })
|
||||
// Events are separated by a blank line (\n\n; tolerate \r\n\r\n via the
|
||||
// per-line \r strip in eventData and a normalized split here).
|
||||
let boundary: number
|
||||
while ((boundary = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
||||
const matched = /\r?\n\r?\n/.exec(buffer.slice(boundary))
|
||||
const block = buffer.slice(0, boundary)
|
||||
// matched cannot be null: search() just found the same pattern at 0.
|
||||
buffer = buffer.slice(boundary + (matched as RegExpExecArray)[0].length)
|
||||
const data = eventData(block)
|
||||
if (data === undefined) continue
|
||||
yield data
|
||||
if (data === DONE) return
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any final un-terminated event (servers usually end with \n\n, but
|
||||
// a trailing block without one is still parseable).
|
||||
buffer += decoder.decode()
|
||||
const data = eventData(buffer)
|
||||
if (data !== undefined) {
|
||||
yield data
|
||||
if (data === DONE) return
|
||||
}
|
||||
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
|
||||
}
|
||||
169
packages/llm/llm-deepseek/src/translate.ts
Normal file
169
packages/llm/llm-deepseek/src/translate.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
|
||||
*
|
||||
* A small state machine over the SSE payload stream:
|
||||
* - `delta.content` / `delta.reasoning_content` / `delta.tool_calls[i]` each
|
||||
* own one harness block (index allocated on first sight). The first
|
||||
* thinking-mode chunk carries `reasoning_content: ""` — that must NOT open
|
||||
* a reasoning block.
|
||||
* - `finish_reason` and `usage` are DEFERRED: emitted only at the `[DONE]`
|
||||
* sentinel, so the wire's two usage shapes (attached to the finish chunk,
|
||||
* or a trailing usage-only chunk) both work and nothing ever follows
|
||||
* `finish`. Last usage wins.
|
||||
*
|
||||
* @module dsh-llm-deepseek/translate
|
||||
*/
|
||||
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE } from './sse'
|
||||
import type { WireChunk, WireUsage } from './types'
|
||||
|
||||
/** One open block under assembly. */
|
||||
interface OpenBlock {
|
||||
index: number
|
||||
kind: 'text' | 'reasoning' | 'tool-call'
|
||||
text: string
|
||||
/** tool-call only */
|
||||
callId?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Map the wire finish_reason vocabulary to the harness FinishReason. */
|
||||
export function mapFinishReason(reason: string): FinishReason {
|
||||
switch (reason) {
|
||||
case 'stop': return { kind: 'stop' }
|
||||
case 'tool_calls': return { kind: 'tool-calls' }
|
||||
case 'length': return { kind: 'max-tokens' }
|
||||
default:
|
||||
// content_filter, insufficient_system_resource, future additions.
|
||||
return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map wire usage fields. DeepSeek's `prompt_tokens` INCLUDES cache hits
|
||||
* (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`,
|
||||
* api/create-chat-completion); the harness TokenUsage convention is
|
||||
* DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
|
||||
*/
|
||||
export function mapUsage(usage: WireUsage): TokenUsage {
|
||||
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
return {
|
||||
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
|
||||
outputTokens: usage.completion_tokens,
|
||||
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
|
||||
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Assemble the final ContentBlock for one open block. */
|
||||
function closeBlock(block: OpenBlock): ContentBlock {
|
||||
switch (block.kind) {
|
||||
case 'text': return { type: 'text', text: block.text }
|
||||
case 'reasoning': return { type: 'reasoning', text: block.text }
|
||||
case 'tool-call': return {
|
||||
type: 'tool-call',
|
||||
id: CallId(block.callId ?? ''),
|
||||
name: block.name ?? '',
|
||||
arguments: block.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
|
||||
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
|
||||
*/
|
||||
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
|
||||
let nextIndex = 0
|
||||
let textBlock: OpenBlock | undefined
|
||||
let reasoningBlock: OpenBlock | undefined
|
||||
const toolBlocks = new Map<number, OpenBlock>()
|
||||
const order: OpenBlock[] = []
|
||||
let pendingFinish: FinishReason | undefined
|
||||
let pendingUsage: TokenUsage | undefined
|
||||
|
||||
function open(kind: OpenBlock['kind']): OpenBlock {
|
||||
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
||||
order.push(block)
|
||||
return block
|
||||
}
|
||||
|
||||
for await (const payload of payloads) {
|
||||
if (payload === DONE) {
|
||||
for (const block of order) {
|
||||
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
||||
}
|
||||
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
|
||||
yield { type: 'finish', reason: pendingFinish ?? { kind: 'stop' } }
|
||||
return
|
||||
}
|
||||
|
||||
let chunk: WireChunk
|
||||
try {
|
||||
chunk = JSON.parse(payload) as WireChunk
|
||||
} catch {
|
||||
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
|
||||
}
|
||||
|
||||
for (const choice of chunk.choices ?? []) {
|
||||
const delta = choice.delta
|
||||
|
||||
// Reasoning first: thinking mode interleaves it before text. The
|
||||
// empty-string first chunk must not open a block.
|
||||
const reasoning = delta?.reasoning_content
|
||||
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
||||
if (!reasoningBlock) {
|
||||
reasoningBlock = open('reasoning')
|
||||
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
|
||||
}
|
||||
reasoningBlock.text += reasoning
|
||||
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
|
||||
}
|
||||
|
||||
const content = delta?.content
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
if (!textBlock) {
|
||||
textBlock = open('text')
|
||||
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
|
||||
}
|
||||
textBlock.text += content
|
||||
yield { type: 'text-delta', index: textBlock.index, text: content }
|
||||
}
|
||||
|
||||
for (const call of delta?.tool_calls ?? []) {
|
||||
let block = toolBlocks.get(call.index)
|
||||
if (!block) {
|
||||
block = open('tool-call')
|
||||
toolBlocks.set(call.index, block)
|
||||
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
||||
}
|
||||
if (call.id !== undefined) block.callId = call.id
|
||||
if (call.function?.name !== undefined) block.name = call.function.name
|
||||
const fragment = call.function?.arguments ?? ''
|
||||
block.text += fragment
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: block.index,
|
||||
id: CallId(block.callId ?? ''),
|
||||
...block.name !== undefined ? { name: block.name } : {},
|
||||
argumentsDelta: fragment,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof choice.finish_reason === 'string') {
|
||||
pendingFinish = mapFinishReason(choice.finish_reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage may arrive attached to the finish chunk or as a trailing
|
||||
// usage-only chunk — keep the latest.
|
||||
if (chunk.usage) pendingUsage = mapUsage(chunk.usage)
|
||||
}
|
||||
|
||||
// parseSse guarantees the [DONE] sentinel (or throws); reaching here means
|
||||
// the payload source violated that contract.
|
||||
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
||||
}
|
||||
136
packages/llm/llm-deepseek/src/types.ts
Normal file
136
packages/llm/llm-deepseek/src/types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DeepSeek chat-completions wire format (OpenAI-compatible). Types only.
|
||||
*
|
||||
* Source of truth: the official API docs at
|
||||
* `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion,
|
||||
* guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against
|
||||
* live streams from the internal endpoint (2026-06).
|
||||
*
|
||||
* @module dsh-llm-deepseek/types
|
||||
*/
|
||||
|
||||
/** Request body for `POST {baseURL}/chat/completions`. */
|
||||
export interface WireRequest {
|
||||
model: string
|
||||
messages: WireMessage[]
|
||||
stream: true
|
||||
stream_options: { include_usage: true }
|
||||
/** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */
|
||||
thinking?: { type: 'enabled' | 'disabled' }
|
||||
/** Thinking effort (official levels; low/medium map to high server-side). */
|
||||
reasoning_effort?: 'high' | 'max'
|
||||
tools?: WireTool[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
/**
|
||||
* Stop sequences (OpenAI `stop`): generation halts as soon as the model
|
||||
* produces any one of these strings. Mapped from `GenerateOptions.stop`.
|
||||
*/
|
||||
stop?: string[]
|
||||
}
|
||||
|
||||
/** System-role message: a single string of instructions. */
|
||||
export interface WireSystemMessage {
|
||||
role: 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
/** User-role message: a single string of user input. */
|
||||
export interface WireUserMessage {
|
||||
role: 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Tool-role message: the result of one tool call, keyed by its call id. */
|
||||
export interface WireToolMessage {
|
||||
role: 'tool'
|
||||
tool_call_id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type WireMessage =
|
||||
| WireSystemMessage
|
||||
| WireUserMessage
|
||||
| WireAssistantMessage
|
||||
| WireToolMessage
|
||||
|
||||
export interface WireAssistantMessage {
|
||||
role: 'assistant'
|
||||
content: string | null
|
||||
/**
|
||||
* CoT passback. REQUIRED on assistant turns that carried tool calls
|
||||
* (thinking mode); ignored on tool-call-free turns (we omit it there to
|
||||
* save tokens). See guides/thinking_mode.mdx § Tool Calls.
|
||||
*/
|
||||
reasoning_content?: string
|
||||
tool_calls?: WireToolCall[]
|
||||
}
|
||||
|
||||
export interface WireToolCall {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: { name: string; arguments: string }
|
||||
}
|
||||
|
||||
export interface WireTool {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
/** Beta: strict schema adherence (official: requires the /beta base URL). */
|
||||
strict?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** One parsed SSE `data:` payload (a chat.completion.chunk). */
|
||||
export interface WireChunk {
|
||||
choices?: WireChoice[]
|
||||
/** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */
|
||||
usage?: WireUsage | null
|
||||
}
|
||||
|
||||
export interface WireChoice {
|
||||
delta?: WireDelta
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
export interface WireDelta {
|
||||
role?: string
|
||||
/** Visible text. Null/empty on reasoning/tool-call chunks. */
|
||||
content?: string | null
|
||||
/**
|
||||
* Thinking-mode CoT. The FIRST chunk carries an empty string (must not
|
||||
* open a reasoning block); absent entirely in non-thinking mode.
|
||||
*/
|
||||
reasoning_content?: string | null
|
||||
tool_calls?: WireToolCallDelta[]
|
||||
}
|
||||
|
||||
export interface WireToolCallDelta {
|
||||
/** Disambiguates parallel tool calls; stable across a call's deltas. */
|
||||
index: number
|
||||
/** Present on the first delta of each call only. */
|
||||
id?: string
|
||||
type?: 'function'
|
||||
function?: {
|
||||
/** Present on the first delta of each call only. */
|
||||
name?: string
|
||||
/** Argument JSON fragment (concatenate across deltas). */
|
||||
arguments?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WireUsage {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
prompt_cache_hit_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
completion_tokens_details?: { reasoning_tokens?: number }
|
||||
}
|
||||
|
||||
/** Non-2xx error body. */
|
||||
export interface WireError {
|
||||
error?: { message?: string; type?: string; code?: string }
|
||||
}
|
||||
149
packages/llm/llm-deepseek/tests/adapter.e2e.ts
Normal file
149
packages/llm/llm-deepseek/tests/adapter.e2e.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { assemble, type AssembledResult } from './assemble.ts'
|
||||
|
||||
/**
|
||||
* Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across
|
||||
* thinking modes and both official effort levels. Key-gated — skips
|
||||
* entirely without $DEEPSEEK_API_KEY (see vitest.e2e.config.ts).
|
||||
*/
|
||||
|
||||
const FLASH = 'deepseek-v4-flash'
|
||||
const PRO = 'deepseek-v4-pro'
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(model: string, config: Partial<Config> = {}) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
function ask(text: string): Message[] {
|
||||
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
||||
}
|
||||
|
||||
function textOf(result: AssembledResult): string {
|
||||
return result.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const weatherTool: ToolSchema = {
|
||||
name: 'get_weather',
|
||||
description: 'Get the current weather for a city.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', description: 'City name' } },
|
||||
required: ['city'],
|
||||
},
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
|
||||
it('flash + thinking disabled: plain text generation', async () => {
|
||||
const ctx = await harness(FLASH, { thinking: 'disabled' })
|
||||
const result = await assemble(ctx,{
|
||||
model: FLASH,
|
||||
messages: ask('Reply with exactly the word: pong'),
|
||||
maxTokens: 50,
|
||||
})
|
||||
expect(result.finish.kind).toBe('stop')
|
||||
expect(textOf(result).toLowerCase()).toContain('pong')
|
||||
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
|
||||
expect(result.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(result.usage?.outputTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
|
||||
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
|
||||
const result = await assemble(ctx,{
|
||||
model: FLASH,
|
||||
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(result.finish.kind).toBe('stop')
|
||||
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
|
||||
expect(textOf(result)).toContain('9.8')
|
||||
expect(result.usage?.reasoningTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(['high', 'max'] as const)(
|
||||
'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback',
|
||||
async (effort) => {
|
||||
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
|
||||
|
||||
// Turn 1: the model must call the tool (and think before it).
|
||||
const first = await assemble(ctx,{
|
||||
model: PRO,
|
||||
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(first.finish.kind).toBe('tool-calls')
|
||||
const call = first.message.content.find(block => block.type === 'tool-call')
|
||||
expect(call).toBeDefined()
|
||||
expect(call!.name).toBe('get_weather')
|
||||
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
|
||||
|
||||
// Turn 2: send the tool result back WITH the assistant's reasoning
|
||||
// block in history (the official thinking+tools passback rule).
|
||||
const second = await assemble(ctx,{
|
||||
model: PRO,
|
||||
messages: [
|
||||
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
{ role: 'assistant', content: first.message.content },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId(call!.id),
|
||||
content: [{ type: 'text', text: 'Sunny, 22°C' }],
|
||||
}],
|
||||
},
|
||||
],
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(second.finish.kind).toBe('stop')
|
||||
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
|
||||
},
|
||||
)
|
||||
|
||||
it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
|
||||
const ctx = await harness(PRO, { thinking: 'disabled' })
|
||||
const result = await assemble(ctx,{
|
||||
model: PRO,
|
||||
messages: ask('Reply with exactly the word: pong'),
|
||||
maxTokens: 50,
|
||||
})
|
||||
expect(result.finish.kind).toBe('stop')
|
||||
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
|
||||
})
|
||||
|
||||
it('streams raw chunks in protocol order', async () => {
|
||||
const ctx = await harness(FLASH, { thinking: 'disabled' })
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
model: FLASH,
|
||||
messages: ask('Count from 1 to 5, digits only.'),
|
||||
maxTokens: 50,
|
||||
})) {
|
||||
kinds.push(chunk.type)
|
||||
}
|
||||
expect(kinds[0]).toBe('block-start')
|
||||
expect(kinds.at(-1)).toBe('finish')
|
||||
expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1)
|
||||
// usage precedes finish (deferred-emit contract)
|
||||
expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish'))
|
||||
})
|
||||
})
|
||||
310
packages/llm/llm-deepseek/tests/adapter.spec.ts
Normal file
310
packages/llm/llm-deepseek/tests/adapter.spec.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { assemble } from './assemble.ts'
|
||||
|
||||
/** One scripted behavior for the next request the mock server receives. */
|
||||
type Behavior =
|
||||
| { kind: 'sse'; events: string[]; delayMs?: number }
|
||||
| { kind: 'http-error'; status: number; body: string; contentType?: string }
|
||||
| { kind: 'close-early'; events: string[] }
|
||||
|
||||
interface MockServer {
|
||||
url: string
|
||||
/** Bodies of received requests, in order. */
|
||||
requests: unknown[]
|
||||
/** Header bags of received requests, in order (parallel to `requests`). */
|
||||
headers: IncomingMessage['headers'][]
|
||||
script: Behavior[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
/** Local chat-completions stand-in: replays scripted behaviors per request. */
|
||||
async function mockServer(script: Behavior[]): Promise<MockServer> {
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
requests.push(JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift()
|
||||
if (!behavior) {
|
||||
response.writeHead(500).end('mock script exhausted')
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'http-error') {
|
||||
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
|
||||
response.end(behavior.body)
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const write = (index: number): void => {
|
||||
if (index >= behavior.events.length) {
|
||||
if (behavior.kind === 'sse') response.end()
|
||||
else response.destroy() // close-early: drop the socket mid-stream
|
||||
return
|
||||
}
|
||||
response.write(`data: ${behavior.events[index]}\n\n`)
|
||||
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
|
||||
}
|
||||
write(0)
|
||||
})
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('no port')
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
headers,
|
||||
script,
|
||||
close: () => new Promise(resolve => server.close(() => { resolve() })),
|
||||
}
|
||||
}
|
||||
|
||||
const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"}}]}',
|
||||
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('DeepSeekAdapter against a mock server', () => {
|
||||
it('streams a text generation end to end through the assembler', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
|
||||
|
||||
// The wire request carried the auth header contents we configured.
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
model: 'deepseek-v4-flash',
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
// Attribution header identifies the harness to the provider.
|
||||
expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//)
|
||||
})
|
||||
|
||||
it('streams raw chunks through ctx.llm.stream', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
const kinds: string[] = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})) {
|
||||
kinds.push(chunk.type)
|
||||
}
|
||||
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
|
||||
})
|
||||
|
||||
it('forwards thinking config onto the wire', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
|
||||
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
thinking: { type: 'disabled' },
|
||||
reasoning_effort: 'high',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, 'AUTH'],
|
||||
[403, 'AUTH'],
|
||||
[429, 'RATE_LIMIT'],
|
||||
[400, 'INVALID_REQUEST'],
|
||||
[500, 'SERVER'],
|
||||
[503, 'SERVER'],
|
||||
])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
|
||||
const behavior: Behavior = {
|
||||
kind: 'http-error',
|
||||
status,
|
||||
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
|
||||
}
|
||||
const server = await mockServer([behavior, behavior, behavior])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(`failed with ${status}`)
|
||||
await expect(
|
||||
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 () => {
|
||||
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/HTTP 500/)
|
||||
})
|
||||
|
||||
it('keeps the status-line message for non-JSON error bodies', async () => {
|
||||
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/HTTP 502/)
|
||||
})
|
||||
|
||||
it('maps unusual statuses to HTTP_<status>', () => {
|
||||
expect(httpErrorCode(418)).toBe('HTTP_418')
|
||||
})
|
||||
|
||||
it('throws EMPTY_RESPONSE when the response has no body', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(null, { status: 200 }),
|
||||
)
|
||||
try {
|
||||
const iterate = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ model: 'm', messages: [] })) { /* drain */ }
|
||||
}
|
||||
await expect(iterate()).rejects.toThrow(/no response body/)
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
|
||||
const server = await mockServer([{
|
||||
kind: 'close-early',
|
||||
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
|
||||
})
|
||||
|
||||
it('aborts mid-stream via the request signal', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }])
|
||||
const ctx = await harness(server.url)
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = (async () => {
|
||||
const chunks = []
|
||||
for await (const chunk of ctx.llm.stream({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
})()
|
||||
|
||||
setTimeout(() => { controller.abort() }, 30)
|
||||
await expect(pending).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin registration and config', () => {
|
||||
it('registers the configured models and unregisters on dispose (HMR safety)', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
})
|
||||
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults the model list', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
})
|
||||
|
||||
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('throws a clear error when no API key is available', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {}))
|
||||
.rejects.toThrow(/an API key is required/)
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers explicit config over env for key and base URL', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const ctx = await harness(server.url) // harness passes explicit config
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
|
||||
})
|
||||
|
||||
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults to the public base URL without config or env', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
// Registration succeeds; no call is made (would hit api.deepseek.com).
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding', () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
||||
})
|
||||
})
|
||||
26
packages/llm/llm-deepseek/tests/assemble.ts
Normal file
26
packages/llm/llm-deepseek/tests/assemble.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
|
||||
* the assembled message + usage + finish reason. This exercises the same
|
||||
* streaming path production uses (the loop), rather than a service-level
|
||||
* one-shot convenience method.
|
||||
*/
|
||||
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export interface AssembledResult {
|
||||
message: Message
|
||||
usage?: TokenUsage
|
||||
finish: FinishReason
|
||||
}
|
||||
|
||||
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
return {
|
||||
message: assembler.message(),
|
||||
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
|
||||
finish: assembler.finish,
|
||||
}
|
||||
}
|
||||
210
packages/llm/llm-deepseek/tests/serialize.spec.ts
Normal file
210
packages/llm/llm-deepseek/tests/serialize.spec.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
||||
return { model: 'deepseek-v4-flash', messages: [], ...overrides }
|
||||
}
|
||||
|
||||
describe('serializeMessages', () => {
|
||||
it('maps user text to string content', () => {
|
||||
const wire = serializeMessages([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
|
||||
})
|
||||
|
||||
it('maps system-role messages in history', () => {
|
||||
const wire = serializeMessages([
|
||||
{ role: 'system', content: [{ type: 'text', text: 'be brief' }] },
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
|
||||
})
|
||||
|
||||
it('maps plain assistant text without reasoning_content', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'thinking…' },
|
||||
{ type: 'text', text: 'answer' },
|
||||
],
|
||||
},
|
||||
])
|
||||
// Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
|
||||
expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
|
||||
})
|
||||
|
||||
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'I should check the weather.' },
|
||||
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([{
|
||||
role: 'assistant',
|
||||
// "" (not null) on tool-call turns — mirrors the official samples'
|
||||
// verbatim message replay; some gateways reject null.
|
||||
content: '',
|
||||
reasoning_content: 'I should check the weather.',
|
||||
tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('serializes parallel tool calls in order', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
},
|
||||
])
|
||||
const assistant = wire[0] as { tool_calls: { id: string }[] }
|
||||
expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('turns tool results into role:tool messages', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId('call-1'),
|
||||
content: [{ type: 'text', text: 'Sunny 22C' }],
|
||||
}],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
|
||||
})
|
||||
|
||||
it('sends a sentinel for empty tool-result content', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
|
||||
})
|
||||
|
||||
it('splits mixed user text + tool results into separate wire messages', () => {
|
||||
const wire = serializeMessages([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'context note' },
|
||||
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([
|
||||
{ role: 'user', content: 'context note' },
|
||||
{ role: 'tool', tool_call_id: 'call-1', content: 'ok' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips image blocks (documented MVP limitation)', () => {
|
||||
const wire = serializeMessages([
|
||||
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
|
||||
})
|
||||
|
||||
it('emits an empty user message rather than dropping block-less messages', () => {
|
||||
const wire = serializeMessages([{ role: 'user', content: [] }])
|
||||
expect(wire).toEqual([{ role: 'user', content: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeRequest', () => {
|
||||
const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
|
||||
|
||||
it('always streams with usage and maps the basics', () => {
|
||||
const wire = serializeRequest(request({ messages: history }))
|
||||
expect(wire).toEqual({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('prepends the system prompt', () => {
|
||||
const wire = serializeRequest(request({ messages: history, system: 'be helpful' }))
|
||||
expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' })
|
||||
expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' })
|
||||
})
|
||||
|
||||
it('maps sampling params and stop sequences', () => {
|
||||
const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] }))
|
||||
expect(wire.temperature).toBe(0.2)
|
||||
expect(wire.max_tokens).toBe(100)
|
||||
expect(wire.stop).toEqual(['END'])
|
||||
})
|
||||
|
||||
it('maps tools with strict passthrough', () => {
|
||||
const wire = serializeRequest(request({
|
||||
messages: history,
|
||||
tools: [
|
||||
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
|
||||
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
|
||||
],
|
||||
}))
|
||||
expect(wire.tools).toEqual([
|
||||
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
|
||||
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits an empty tools array', () => {
|
||||
const wire = serializeRequest(request({ messages: history, tools: [] }))
|
||||
expect(wire.tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies adapter defaults for thinking and effort', () => {
|
||||
const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' })
|
||||
expect(wire.thinking).toEqual({ type: 'enabled' })
|
||||
expect(wire.reasoning_effort).toBe('max')
|
||||
})
|
||||
|
||||
it('omits thinking fields when unset (provider default applies)', () => {
|
||||
const wire = serializeRequest(request({ messages: history }))
|
||||
expect(wire.thinking).toBeUndefined()
|
||||
expect(wire.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects prefill with an UNSUPPORTED LlmError', () => {
|
||||
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
|
||||
.toThrow(LlmError)
|
||||
try {
|
||||
serializeRequest(request({ prefill: [] }))
|
||||
expect.unreachable()
|
||||
} catch (error) {
|
||||
expect((error as LlmError).code).toBe('UNSUPPORTED')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: assistant content shapes', () => {
|
||||
it('serializes a content-less, tool-call-less assistant message as null content', () => {
|
||||
// Aborted/empty assistant turns: no text, no calls → null (the wire
|
||||
// accepts it; "" is reserved for tool-call turns per the samples).
|
||||
const wire = serializeMessages([{ role: 'assistant', content: [] }])
|
||||
expect(wire).toEqual([{ role: 'assistant', content: null }])
|
||||
})
|
||||
|
||||
it('serializes tool-call turns with empty string content, not null', () => {
|
||||
const wire = serializeMessages([{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
|
||||
}])
|
||||
expect(wire[0]).toMatchObject({ content: '' })
|
||||
})
|
||||
})
|
||||
108
packages/llm/llm-deepseek/tests/sse.spec.ts
Normal file
108
packages/llm/llm-deepseek/tests/sse.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
/** Build a byte stream from string fragments (fragments = network reads). */
|
||||
async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
for (const fragment of fragments) {
|
||||
yield typeof fragment === 'string' ? encoder.encode(fragment) : fragment
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(stream: AsyncIterable<string>): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
for await (const item of stream) out.push(item)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('parseSse', () => {
|
||||
it('parses simple events and the DONE sentinel', async () => {
|
||||
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('handles events split across reads at arbitrary positions', async () => {
|
||||
const events = await collect(parseSse(bytes('da', 'ta: {"a"', ':1}\n', '\ndata: [DO', 'NE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('handles multi-byte UTF-8 split across reads', async () => {
|
||||
const encoded = new TextEncoder().encode('data: {"text":"日本語"}\n\ndata: [DONE]\n\n')
|
||||
// Split inside the 3-byte sequence for 日.
|
||||
const splitAt = 16
|
||||
const events = await collect(parseSse(bytes(encoded.slice(0, splitAt), encoded.slice(splitAt))))
|
||||
expect(events).toEqual(['{"text":"日本語"}', DONE])
|
||||
})
|
||||
|
||||
it('tolerates CRLF line endings', async () => {
|
||||
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata: [DONE]\r\n\r\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('joins multi-data events with newlines (SSE spec)', async () => {
|
||||
const events = await collect(parseSse(bytes('data: line1\ndata: line2\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['line1\nline2', DONE])
|
||||
})
|
||||
|
||||
it('ignores comments and non-data fields', async () => {
|
||||
const events = await collect(parseSse(bytes(': keepalive\nevent: chunk\nid: 7\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('skips blocks without data fields', async () => {
|
||||
const events = await collect(parseSse(bytes(': ping\n\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('preserves data lines without the optional space', async () => {
|
||||
const events = await collect(parseSse(bytes('data:{"a":1}\n\ndata:[DONE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('parses several events from one read', async () => {
|
||||
const events = await collect(parseSse(bytes('data: 1\n\ndata: 2\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['1', '2', DONE])
|
||||
})
|
||||
|
||||
it('flushes a final un-terminated DONE at stream end', async () => {
|
||||
const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('throws STREAM_CLOSED when the stream ends without DONE', async () => {
|
||||
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError)
|
||||
await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/)
|
||||
})
|
||||
|
||||
it('throws STREAM_CLOSED for an empty stream', async () => {
|
||||
await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/)
|
||||
})
|
||||
|
||||
it('throws STREAM_CLOSED for a mid-event close', async () => {
|
||||
await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/)
|
||||
})
|
||||
|
||||
it('stops yielding after DONE even when more data follows', async () => {
|
||||
const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n')))
|
||||
expect(events).toEqual([DONE])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSse edge branches', () => {
|
||||
it('handles a lone CR-terminated data line', async () => {
|
||||
// Exercises the \r-strip branch on a line that is ONLY "data:…\r".
|
||||
const events = await collect(parseSse(bytes('data: {"a":1}\r\n\r\ndata:[DONE]\r\n\r\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('strips CR from non-data field lines too', async () => {
|
||||
const events = await collect(parseSse(bytes('event: chunk\r\ndata: {"a":1}\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['{"a":1}', DONE])
|
||||
})
|
||||
|
||||
it('treats bare "data:" lines as empty payload entries', async () => {
|
||||
const events = await collect(parseSse(bytes('data:\ndata: x\n\ndata: [DONE]\n\n')))
|
||||
expect(events).toEqual(['\nx', DONE])
|
||||
})
|
||||
})
|
||||
307
packages/llm/llm-deepseek/tests/translate.spec.ts
Normal file
307
packages/llm/llm-deepseek/tests/translate.spec.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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'
|
||||
|
||||
async function* feed(...payloads: (string | object)[]): AsyncGenerator<string> {
|
||||
for (const payload of payloads) {
|
||||
yield typeof payload === 'string' ? payload : JSON.stringify(payload)
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
|
||||
const out: StreamChunk[] = []
|
||||
for await (const chunk of stream) out.push(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
/** The live first-chunk signature: role + null content + EMPTY reasoning. */
|
||||
const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] }
|
||||
|
||||
describe('translate: text', () => {
|
||||
it('streams a text block and defers finish to DONE', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'Hel' } }] },
|
||||
{ choices: [{ delta: { content: 'lo' } }] },
|
||||
{ choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'Hel' },
|
||||
{ type: 'text-delta', index: 0, text: 'lo' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } },
|
||||
{ type: 'usage', usage: { inputTokens: 5, outputTokens: 2 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('assembles into the message BlockAssembler expects', async () => {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'hi' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
DONE,
|
||||
))) {
|
||||
assembler.push(chunk)
|
||||
}
|
||||
const result = { message: assembler.message(), finish: assembler.finish }
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('translate: reasoning', () => {
|
||||
it('does NOT open a reasoning block for the empty first-chunk signature', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'plain' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false)
|
||||
})
|
||||
|
||||
it('streams reasoning then text as separate blocks', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: null, reasoning_content: 'think' } }] },
|
||||
{ choices: [{ delta: { content: null, reasoning_content: 'ing' } }] },
|
||||
{ choices: [{ delta: { content: 'answer', reasoning_content: null } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'think' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'ing' },
|
||||
{ type: 'block-start', index: 1, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 1, text: 'answer' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } },
|
||||
{ type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an entirely absent reasoning_content field as non-thinking', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
{ choices: [{ delta: { role: 'assistant', content: 'x' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('translate: tool calls', () => {
|
||||
it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] },
|
||||
{ choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' },
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' },
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' },
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 28, outputTokens: 6 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('disambiguates parallel tool calls by wire index', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{
|
||||
choices: [{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } },
|
||||
{ index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||
DONE,
|
||||
)))
|
||||
const ends = chunks.filter(chunk => chunk.type === 'block-end')
|
||||
expect(ends).toEqual([
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('interleaves text and tool-call blocks with distinct indices', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'Checking.' } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||
DONE,
|
||||
)))
|
||||
const starts = chunks.filter(chunk => chunk.type === 'block-start')
|
||||
expect(starts).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('translate: finish and usage handling', () => {
|
||||
it('takes usage from a trailing usage-only chunk (docs shape)', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'x' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1 } })
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
|
||||
})
|
||||
|
||||
it('last usage wins when both attached and trailing arrive', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } },
|
||||
{ choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } },
|
||||
DONE,
|
||||
)))
|
||||
const usage = chunks.find(chunk => chunk.type === 'usage')
|
||||
expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2 } })
|
||||
})
|
||||
|
||||
it('defaults to finish stop when no finish_reason ever arrives', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: 'x' } }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
|
||||
})
|
||||
|
||||
it('omits the usage chunk when none arrived', async () => {
|
||||
const chunks = await collect(translate(feed(firstChunk, DONE)))
|
||||
expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles chunks with no choices at all', async () => {
|
||||
const chunks = await collect(translate(feed({}, DONE)))
|
||||
expect(chunks).toEqual([{ type: 'finish', reason: { kind: 'stop' } }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('translate: errors', () => {
|
||||
it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => {
|
||||
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError)
|
||||
await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/)
|
||||
})
|
||||
|
||||
it('throws STREAM_CLOSED when the payload source ends without DONE', async () => {
|
||||
await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapFinishReason', () => {
|
||||
it.each([
|
||||
['stop', { kind: 'stop' }],
|
||||
['tool_calls', { kind: 'tool-calls' }],
|
||||
['length', { kind: 'max-tokens' }],
|
||||
])('maps %s', (wire, expected) => {
|
||||
expect(mapFinishReason(wire)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])(
|
||||
'maps %s to an error kind with the wire code',
|
||||
(wire) => {
|
||||
expect(mapFinishReason(wire)).toEqual({
|
||||
kind: 'error',
|
||||
message: `model stopped: ${wire}`,
|
||||
code: wire.toUpperCase(),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('mapUsage', () => {
|
||||
it('maps the full live-capture shape', () => {
|
||||
expect(mapUsage({
|
||||
prompt_tokens: 283,
|
||||
completion_tokens: 69,
|
||||
prompt_cache_hit_tokens: 256,
|
||||
prompt_cache_miss_tokens: 27,
|
||||
prompt_tokens_details: { cached_tokens: 256 },
|
||||
completion_tokens_details: { reasoning_tokens: 24 },
|
||||
})).toEqual({
|
||||
// 283 wire prompt_tokens minus the 256 cached → 27 uncached input
|
||||
// (TokenUsage counts are disjoint).
|
||||
inputTokens: 27,
|
||||
outputTokens: 69,
|
||||
cacheReadTokens: 256,
|
||||
reasoningTokens: 24,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to prompt_cache_hit_tokens when details are absent', () => {
|
||||
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 }))
|
||||
.toEqual({ inputTokens: 2, outputTokens: 2, cacheReadTokens: 8 })
|
||||
})
|
||||
|
||||
it('omits optional fields when the wire omits them', () => {
|
||||
expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 }))
|
||||
.toEqual({ inputTokens: 10, outputTokens: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('translate: defensive tool-call branches', () => {
|
||||
it('handles deltas that never carry id or name (empty-string fallbacks)', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
// Hypothetical lenient wire: argument fragments with no id/name at all.
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('handles tool_call deltas with a function object but no arguments field', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' })
|
||||
})
|
||||
|
||||
it('handles tool_call deltas with no function object at all', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' })
|
||||
})
|
||||
})
|
||||
24
packages/llm/llm-deepseek/tsconfig.json
Normal file
24
packages/llm/llm-deepseek/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
38
packages/llm/llm-pi-ai/README.md
Normal file
38
packages/llm/llm-pi-ai/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# @deepseek-ai/dsh-llm-pi-ai
|
||||
|
||||
DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) (the LLM library behind the pi agent).
|
||||
|
||||
## Why a second adapter exists
|
||||
|
||||
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
|
||||
|
||||
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
|
||||
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
|
||||
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
|
||||
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
|
||||
|
||||
## Config
|
||||
|
||||
Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-level vocabulary:
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
models: [deepseek-v4-flash, deepseek-v4-pro]
|
||||
reasoning: high # off | high | xhigh (xhigh → wire 'max')
|
||||
```
|
||||
|
||||
## Dependency weight
|
||||
|
||||
pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification.
|
||||
|
||||
## Limitations
|
||||
|
||||
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK happily talks to any base URL). Real-API coverage in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across all exposed reasoning levels (off/high/xhigh), the thinking+tools round trip, and a cross-adapter structural-equivalence check against llm-deepseek.
|
||||
37
packages/llm/llm-pi-ai/package.json
Normal file
37
packages/llm/llm-pi-ai/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-llm-pi-ai",
|
||||
"description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)",
|
||||
"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-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.79.1",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
188
packages/llm/llm-pi-ai/src/adapter.ts
Normal file
188
packages/llm/llm-pi-ai/src/adapter.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* `PiAiAdapter`: the `@earendil-works/pi-ai`-backed implementation of the
|
||||
* harness LLM seam, pointed at a DeepSeek (OpenAI-compatible) endpoint.
|
||||
*
|
||||
* This adapter exists as a design-verification twin of
|
||||
* `@deepseek-ai/dsh-llm-deepseek`: same models, same wire protocol,
|
||||
* completely different internals (a unified LLM library with its own event
|
||||
* vocabulary vs hand-rolled fetch/SSE). Anything the StreamChunk protocol
|
||||
* cannot express for BOTH implementations is a core-vocabulary bug.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/adapter
|
||||
*/
|
||||
|
||||
import { stream as piStream } from '@earendil-works/pi-ai'
|
||||
import type { Model } from '@earendil-works/pi-ai'
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext, toStreamChunks } from './convert'
|
||||
|
||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||
export type PiAiReasoning = 'off' | 'high' | 'xhigh'
|
||||
|
||||
export interface PiAiAdapterOptions {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
/** Thinking level applied to every request ('off' disables thinking). */
|
||||
reasoning?: PiAiReasoning | undefined
|
||||
}
|
||||
|
||||
/** Build the inline pi-ai model descriptor for one DeepSeek model name. */
|
||||
export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<'openai-completions'> {
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
baseUrl: options.baseURL,
|
||||
// Always true: pi-ai only emits the DeepSeek `thinking` field for
|
||||
// reasoning-capable models, deriving enabled/disabled from whether a
|
||||
// reasoningEffort option is passed. DeepSeek's provider default is
|
||||
// ENABLED, so 'off' must send an explicit {type: 'disabled'} — which
|
||||
// requires this flag to stay on.
|
||||
reasoning: true,
|
||||
// DeepSeek's official effort levels: high|max (xhigh maps to max).
|
||||
thinkingLevelMap: { minimal: null, low: null, medium: null, high: 'high', xhigh: 'max' },
|
||||
input: ['text'],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 64_000,
|
||||
compat: {
|
||||
// Auto-detection only fires for *.deepseek.com base URLs; the internal
|
||||
// endpoint (and test mocks) need these set explicitly.
|
||||
thinkingFormat: 'deepseek',
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
supportsReasoningEffort: true,
|
||||
// DeepSeek documents max_tokens (not OpenAI's max_completion_tokens).
|
||||
maxTokensField: 'max_tokens',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Payload = {
|
||||
tools?: { function?: { name?: unknown; strict?: unknown } }[]
|
||||
messages?: {
|
||||
role?: unknown
|
||||
tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[]
|
||||
}[]
|
||||
reasoning_effort?: unknown
|
||||
stop?: unknown
|
||||
}
|
||||
|
||||
function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
|
||||
const raw = new Map<CallId, string>()
|
||||
for (const message of options.messages) {
|
||||
if (message.role !== 'assistant') continue
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'tool-call') raw.set(block.id, block.arguments)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> {
|
||||
return new Map((tools ?? []).map(tool => [tool.name, tool.strict]))
|
||||
}
|
||||
|
||||
function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown {
|
||||
/* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */
|
||||
if (typeof payload !== 'object' || payload === null) return payload
|
||||
const body = payload as Payload
|
||||
|
||||
if (reasoning === undefined) {
|
||||
delete body.reasoning_effort
|
||||
}
|
||||
if (options.stop !== undefined) {
|
||||
body.stop = options.stop
|
||||
}
|
||||
|
||||
const strictByName = strictByToolName(options.tools)
|
||||
for (const tool of body.tools ?? []) {
|
||||
/* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */
|
||||
if (tool.function === undefined) continue
|
||||
const name = tool.function.name
|
||||
/* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */
|
||||
if (typeof name !== 'string') continue
|
||||
const strict = strictByName.get(name)
|
||||
if (strict === undefined) delete tool.function.strict
|
||||
else tool.function.strict = strict
|
||||
}
|
||||
|
||||
const rawById = rawToolArguments(options)
|
||||
/* v8 ignore next -- defensive for non-chat payloads; OpenAI chat payloads always carry messages */
|
||||
for (const message of body.messages ?? []) {
|
||||
if (message.role !== 'assistant') continue
|
||||
/* v8 ignore next -- assistant messages without tool_calls need no raw-argument patch */
|
||||
for (const call of message.tool_calls ?? []) {
|
||||
/* v8 ignore next -- malformed pi-ai payload guard: real tool calls always carry a string id */
|
||||
if (typeof call.id !== 'string') continue
|
||||
const raw = rawById.get(CallId(call.id))
|
||||
/* v8 ignore next -- pi-ai always emits a function object for assistant tool_calls; guard malformed payloads defensively */
|
||||
if (raw !== undefined && call.function !== undefined) call.function.arguments = raw
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* pi-ai-backed adapter. One instance serves every registered model name.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - `onPayload` patches provider payload details pi-ai cannot express directly:
|
||||
* stop sequences, per-tool strict, omitted reasoning effort, and raw replayed
|
||||
* tool-call arguments.
|
||||
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
|
||||
* - pi-ai reports request failures as in-stream error events; convert.ts
|
||||
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
|
||||
* throwing — both are sanctioned StreamChunk error paths.
|
||||
*/
|
||||
export class PiAiAdapter extends LlmAdapter {
|
||||
constructor(private readonly options: PiAiAdapterOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.prefill !== undefined) {
|
||||
throw new LlmError(
|
||||
'prefill is not supported by the pi-ai adapter',
|
||||
'UNSUPPORTED',
|
||||
)
|
||||
}
|
||||
|
||||
const model = buildModel(options.model, this.options)
|
||||
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
|
||||
// matching llm-deepseek's omission semantics. pi-ai derives the wire
|
||||
// thinking toggle from whether reasoningEffort is passed, so undefined maps
|
||||
// internally to 'high' to get `thinking: enabled`; patchPayload then removes
|
||||
// `reasoning_effort` so the provider chooses its default effort.
|
||||
const reasoning = this.options.reasoning ?? 'high'
|
||||
|
||||
// pi-ai's event stream has no iterator-return cancellation hook: if our
|
||||
// consumer stops early (break / loop abort), the underlying HTTP stream
|
||||
// would keep draining. Chain an internal controller onto the caller's
|
||||
// signal and abort it when this generator exits for any reason.
|
||||
const controller = new AbortController()
|
||||
const onCallerAbort = (): void => { controller.abort(options.signal?.reason) }
|
||||
if (options.signal?.aborted) controller.abort(options.signal.reason)
|
||||
else options.signal?.addEventListener('abort', onCallerAbort, { once: true })
|
||||
|
||||
try {
|
||||
const events = piStream(model, toPiContext(options), {
|
||||
apiKey: this.options.apiKey,
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
|
||||
signal: controller.signal,
|
||||
...reasoning !== 'off' ? { reasoningEffort: reasoning } : {},
|
||||
onPayload: payload => patchPayload(payload, options, this.options.reasoning),
|
||||
maxRetries: 0,
|
||||
})
|
||||
|
||||
yield* toStreamChunks(events)
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', onCallerAbort)
|
||||
controller.abort('consumer stopped streaming')
|
||||
}
|
||||
}
|
||||
}
|
||||
276
packages/llm/llm-pi-ai/src/convert.ts
Normal file
276
packages/llm/llm-pi-ai/src/convert.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Bidirectional mapping between the harness vocabulary and pi-ai's:
|
||||
* `GenerateOptions`/`Message[]` → pi-ai `Context`, and pi-ai
|
||||
* `AssistantMessageEvent`s → harness `StreamChunk`s.
|
||||
*
|
||||
* Vocabulary differences worth knowing (they are exactly why this adapter
|
||||
* exists — an independent implementation stress-tests the StreamChunk
|
||||
* protocol):
|
||||
* - pi-ai tool-call `arguments` are PARSED OBJECTS; the harness keeps the
|
||||
* raw JSON string. We parse on the way into pi-ai, patch provider payloads
|
||||
* back to the original raw string in the adapter, and re-stringify on output.
|
||||
* - pi-ai reports errors as in-stream `error` events (it never throws
|
||||
* mid-stream); the harness expresses those as `finish {kind:'error'}` /
|
||||
* `{kind:'aborted'}` chunks.
|
||||
* - pi-ai folds reasoning tokens into `usage.output`; there is no separate
|
||||
* reasoning count to map.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/convert
|
||||
*/
|
||||
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, Message, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
Context as PiContext,
|
||||
Message as PiMessage,
|
||||
Tool as PiTool,
|
||||
Usage as PiUsage,
|
||||
} from '@earendil-works/pi-ai'
|
||||
|
||||
/** Join the text blocks of a harness message. */
|
||||
function flattenText(message: Message): string {
|
||||
return message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
|
||||
function parseArguments(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert harness history to a pi-ai Context. Tool results need the tool
|
||||
* NAME (pi-ai's `toolName`), which the harness doesn't carry on the result
|
||||
* block — it is recovered from the preceding assistant tool-call with the
|
||||
* same id.
|
||||
*/
|
||||
export function toPiContext(options: GenerateOptions): PiContext {
|
||||
const toolNames = new Map<CallId, string>()
|
||||
const messages: PiMessage[] = []
|
||||
|
||||
for (const message of options.messages) {
|
||||
if (message.role === 'system') {
|
||||
// pi-ai has a single systemPrompt slot; in-history system messages are
|
||||
// folded into user messages to preserve order (rare in practice — the
|
||||
// harness sends the system prompt via options.system).
|
||||
messages.push({ role: 'user', content: flattenText(message), timestamp: 0 })
|
||||
continue
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
const content: AssistantMessage['content'] = []
|
||||
for (const block of message.content) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
content.push({ type: 'text', text: block.text })
|
||||
break
|
||||
case 'reasoning':
|
||||
// thinkingSignature names the wire field pi-ai replays the CoT
|
||||
// under. Without it pi-ai falls back to reasoning_content: ""
|
||||
// (its requiresReasoningContentOnAssistantMessages shim), which
|
||||
// violates DeepSeek's thinking-mode passback rule on tool-call
|
||||
// turns (guides/thinking_mode.mdx § Tool Calls).
|
||||
content.push({ type: 'thinking', thinking: block.text, thinkingSignature: 'reasoning_content' })
|
||||
break
|
||||
case 'tool-call':
|
||||
toolNames.set(block.id, block.name)
|
||||
content.push({
|
||||
type: 'toolCall',
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: parseArguments(block.arguments),
|
||||
})
|
||||
break
|
||||
default:
|
||||
// image / plugin-added block types: not representable here.
|
||||
break
|
||||
}
|
||||
}
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: options.model,
|
||||
usage: emptyPiUsage(),
|
||||
stopReason: content.some(piece => piece.type === 'toolCall') ? 'toolUse' : 'stop',
|
||||
timestamp: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// user role: text + tool results (each result becomes its own message).
|
||||
const text = flattenText(message)
|
||||
const results = message.content.filter(block => block.type === 'tool-result')
|
||||
if (text.length > 0 || results.length === 0) {
|
||||
messages.push({ role: 'user', content: text, timestamp: 0 })
|
||||
}
|
||||
for (const result of results) {
|
||||
messages.push({
|
||||
role: 'toolResult',
|
||||
toolCallId: result.toolCallId,
|
||||
toolName: toolNames.get(result.toolCallId) ?? 'unknown',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('') || '(no output)',
|
||||
}],
|
||||
isError: result.isError ?? false,
|
||||
timestamp: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tools: PiTool[] | undefined = options.tools?.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema
|
||||
// (TypeBox) is structurally JSON Schema, so it assigns directly.
|
||||
parameters: tool.parameters,
|
||||
}))
|
||||
|
||||
return {
|
||||
...options.system !== undefined ? { systemPrompt: options.system } : {},
|
||||
messages,
|
||||
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPiUsage(): PiUsage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Map pi-ai usage (reasoning folded into output by pi-ai). */
|
||||
export function mapUsage(usage: PiUsage): TokenUsage {
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
||||
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function classifyPiAiError(message: string): string {
|
||||
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
|
||||
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
|
||||
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
|
||||
if (/\b5\d\d\b/.test(message)) return 'SERVER'
|
||||
return 'PI_AI_ERROR'
|
||||
}
|
||||
|
||||
/** Map a terminal pi-ai event to the harness finish reason. */
|
||||
export function mapStopReason(message: AssistantMessage): FinishReason {
|
||||
switch (message.stopReason) {
|
||||
case 'stop': return { kind: 'stop' }
|
||||
case 'length': return { kind: 'max-tokens' }
|
||||
case 'toolUse': return { kind: 'tool-calls' }
|
||||
case 'aborted': return { kind: 'aborted' }
|
||||
case 'error': {
|
||||
const text = message.errorMessage ?? 'pi-ai stream error'
|
||||
return { kind: 'error', message: text, code: classifyPiAiError(text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
|
||||
* mid-stream — failures arrive as `error` events, which become error/aborted
|
||||
* `finish` chunks (the harness protocol's other error-delivery style).
|
||||
*/
|
||||
export async function* toStreamChunks(events: AsyncIterable<AssistantMessageEvent>): AsyncGenerator<StreamChunk> {
|
||||
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
|
||||
// in stream order), but we track ids per index for tool calls.
|
||||
const toolIds = new Map<number, { id: string; name: string }>()
|
||||
|
||||
for await (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'start':
|
||||
break
|
||||
case 'text_start':
|
||||
yield { type: 'block-start', index: event.contentIndex, blockType: 'text' }
|
||||
break
|
||||
case 'text_delta':
|
||||
yield { type: 'text-delta', index: event.contentIndex, text: event.delta }
|
||||
break
|
||||
case 'text_end':
|
||||
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } }
|
||||
break
|
||||
case 'thinking_start':
|
||||
yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' }
|
||||
break
|
||||
case 'thinking_delta':
|
||||
yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta }
|
||||
break
|
||||
case 'thinking_end':
|
||||
yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } }
|
||||
break
|
||||
case 'toolcall_start': {
|
||||
// The id/name live on the partial's content at this index.
|
||||
const partial = event.partial.content[event.contentIndex]
|
||||
const id = partial?.type === 'toolCall' ? partial.id : ''
|
||||
const name = partial?.type === 'toolCall' ? partial.name : ''
|
||||
toolIds.set(event.contentIndex, { id, name })
|
||||
yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' }
|
||||
break
|
||||
}
|
||||
case 'toolcall_delta': {
|
||||
const known = toolIds.get(event.contentIndex)
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: event.contentIndex,
|
||||
id: CallId(known?.id ?? ''),
|
||||
...known?.name !== undefined && known.name.length > 0 ? { name: known.name } : {},
|
||||
argumentsDelta: event.delta,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'toolcall_end':
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: event.contentIndex,
|
||||
block: {
|
||||
type: 'tool-call',
|
||||
id: CallId(event.toolCall.id),
|
||||
name: event.toolCall.name,
|
||||
// pi-ai hands back the PARSED arguments; the harness vocabulary
|
||||
// keeps the raw string.
|
||||
arguments: JSON.stringify(event.toolCall.arguments),
|
||||
},
|
||||
}
|
||||
break
|
||||
case 'done':
|
||||
yield { type: 'usage', usage: mapUsage(event.message.usage) }
|
||||
yield { type: 'finish', reason: mapStopReason(event.message) }
|
||||
return
|
||||
case 'error':
|
||||
// In-stream error delivery (pi-ai's style) → error finish chunk
|
||||
// (the harness's other sanctioned error path besides throwing).
|
||||
yield { type: 'usage', usage: mapUsage(event.error.usage) }
|
||||
yield { type: 'finish', reason: mapStopReason(event.error) }
|
||||
return
|
||||
// no default: AssistantMessageEvent is pi-ai's closed union; a new
|
||||
// event type should fail compilation here via tsc's exhaustiveness
|
||||
// when one is added (switch covers all current variants).
|
||||
}
|
||||
}
|
||||
throw new LlmError('pi-ai event stream ended without done/error', 'STREAM_CLOSED')
|
||||
}
|
||||
71
packages/llm/llm-pi-ai/src/index.ts
Normal file
71
packages/llm/llm-pi-ai/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* pi-ai-backed DeepSeek adapter plugin. Same Config shape as
|
||||
* `@deepseek-ai/dsh-llm-deepseek` (one-line swap in cordis.yml), different
|
||||
* implementation underneath — see `./adapter.ts` for why both exist.
|
||||
*
|
||||
* ```yaml
|
||||
* - id: llm
|
||||
* name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
* config:
|
||||
* apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
* models: [deepseek-v4-flash, deepseek-v4-pro]
|
||||
* reasoning: high
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-pi-ai
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { PiAiAdapter } from './adapter'
|
||||
import type { PiAiReasoning } from './adapter'
|
||||
|
||||
export { buildModel, PiAiAdapter } from './adapter'
|
||||
export type { PiAiAdapterOptions, PiAiReasoning } from './adapter'
|
||||
export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert'
|
||||
|
||||
export const name = 'llm-pi-ai'
|
||||
export const inject = ['llm']
|
||||
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/**
|
||||
* Thinking level for every request: 'off' disables thinking mode; 'high'
|
||||
* and 'xhigh' (wire 'max') set the effort. Omitted = provider default
|
||||
* (thinking enabled), matching llm-deepseek's omission semantics.
|
||||
*/
|
||||
reasoning?: PiAiReasoning
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
models: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v4-pro']),
|
||||
reasoning: z.union(['off', 'high', 'xhigh']),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error('llm-pi-ai: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
|
||||
}
|
||||
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
|
||||
// schemastery's .default() guarantees models is set after validation.
|
||||
const models = config.models as string[]
|
||||
|
||||
ctx.llm.registerAdapter(models, new PiAiAdapter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
reasoning: config.reasoning,
|
||||
}))
|
||||
}
|
||||
138
packages/llm/llm-pi-ai/tests/adapter.e2e.ts
Normal file
138
packages/llm/llm-pi-ai/tests/adapter.e2e.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import type { Config } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { assemble, type AssembledResult } from './assemble.ts'
|
||||
|
||||
/**
|
||||
* Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro across all
|
||||
* reasoning levels the adapter exposes (off / high / xhigh→wire 'max').
|
||||
* Mirrors the llm-deepseek matrix so the two independent implementations
|
||||
* verify the same StreamChunk contract. Key-gated.
|
||||
*/
|
||||
|
||||
const FLASH = 'deepseek-v4-flash'
|
||||
const PRO = 'deepseek-v4-pro'
|
||||
const contexts: Context[] = []
|
||||
|
||||
async function harness(model: string, config: Partial<Config> = {}) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, { models: [model], ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
function ask(text: string): Message[] {
|
||||
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
||||
}
|
||||
|
||||
function textOf(result: AssembledResult): string {
|
||||
return result.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function blockKinds(result: AssembledResult): string[] {
|
||||
return result.message.content.map(block => block.type)
|
||||
}
|
||||
|
||||
const weatherTool: ToolSchema = {
|
||||
name: 'get_weather',
|
||||
description: 'Get the current weather for a city.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', description: 'City name' } },
|
||||
required: ['city'],
|
||||
},
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => {
|
||||
it.each([FLASH, PRO])('%s + reasoning off: plain text generation', async (model) => {
|
||||
const ctx = await harness(model, { reasoning: 'off' })
|
||||
const result = await assemble(ctx,{
|
||||
model,
|
||||
messages: ask('Reply with exactly the word: pong'),
|
||||
maxTokens: 50,
|
||||
})
|
||||
expect(result.finish.kind).toBe('stop')
|
||||
expect(textOf(result).toLowerCase()).toContain('pong')
|
||||
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => {
|
||||
const ctx = await harness(model, { reasoning: 'high' })
|
||||
const result = await assemble(ctx,{
|
||||
model,
|
||||
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(result.finish.kind).toBe('stop')
|
||||
expect(result.message.content.some(block => block.type === 'reasoning')).toBe(true)
|
||||
expect(textOf(result)).toContain('9.8')
|
||||
})
|
||||
|
||||
it('pro + reasoning xhigh (wire max): tool-call round trip', async () => {
|
||||
const ctx = await harness(PRO, { reasoning: 'xhigh' })
|
||||
|
||||
const first = await assemble(ctx,{
|
||||
model: PRO,
|
||||
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(first.finish.kind).toBe('tool-calls')
|
||||
const call = first.message.content.find(block => block.type === 'tool-call')
|
||||
expect(call).toBeDefined()
|
||||
expect(call!.name).toBe('get_weather')
|
||||
expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string })
|
||||
|
||||
const second = await assemble(ctx,{
|
||||
model: PRO,
|
||||
messages: [
|
||||
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
|
||||
{ role: 'assistant', content: first.message.content },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: CallId(call!.id),
|
||||
content: [{ type: 'text', text: 'Sunny, 22°C' }],
|
||||
}],
|
||||
},
|
||||
],
|
||||
tools: [weatherTool],
|
||||
maxTokens: 2000,
|
||||
})
|
||||
expect(second.finish.kind).toBe('stop')
|
||||
expect(textOf(second).toLowerCase()).toMatch(/sunny|22/)
|
||||
})
|
||||
|
||||
it('produces the same block structure as llm-deepseek for the same prompt', async () => {
|
||||
// Loose structural equivalence between the two independent adapters:
|
||||
// same block KINDS in the same order for a deterministic prompt — the
|
||||
// cross-implementation check that the StreamChunk design holds.
|
||||
const deepseekCtx = new Context()
|
||||
contexts.push(deepseekCtx)
|
||||
await deepseekCtx.plugin(LlmService)
|
||||
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })
|
||||
|
||||
const piCtx = await harness(FLASH, { reasoning: 'off' })
|
||||
|
||||
const prompt = ask('Reply with exactly the word: pong')
|
||||
const [fromDeepSeek, fromPiAi] = await Promise.all([
|
||||
assemble(deepseekCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
|
||||
assemble(piCtx, { model: FLASH, messages: prompt, maxTokens: 50 }),
|
||||
])
|
||||
expect(blockKinds(fromPiAi)).toEqual(blockKinds(fromDeepSeek))
|
||||
expect(fromPiAi.finish.kind).toBe(fromDeepSeek.finish.kind)
|
||||
})
|
||||
})
|
||||
403
packages/llm/llm-pi-ai/tests/adapter.spec.ts
Normal file
403
packages/llm/llm-pi-ai/tests/adapter.spec.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { assemble } from './assemble.ts'
|
||||
|
||||
/** Scripted SSE responses, one per request (OpenAI chat-completions shape). */
|
||||
interface MockServer {
|
||||
url: string
|
||||
requests: unknown[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
})
|
||||
|
||||
async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise<MockServer> {
|
||||
const requests: unknown[] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
requests.push(JSON.parse(body))
|
||||
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
|
||||
if (behavior.status !== undefined && behavior.status !== 200) {
|
||||
response.writeHead(behavior.status, { 'content-type': 'application/json' })
|
||||
response.end(behavior.body ?? '{}')
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const event of behavior.events ?? []) response.write(`data: ${event}\n\n`)
|
||||
response.end()
|
||||
})
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('no port')
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () => new Promise(resolve => server.close(() => { resolve() })),
|
||||
}
|
||||
}
|
||||
|
||||
const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
const toolEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":null},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"get_weather","arguments":""}}]},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\":\\"Paris\\"}"}}]},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":6}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
const thinkingEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"reasoning_content":"pondering"},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"content":"answer","reasoning_content":null},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":9}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('PiAiAdapter against a mock server', () => {
|
||||
it('streams a text generation through the assembler', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
})
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(result.usage).toMatchObject({ inputTokens: 3, outputTokens: 1 })
|
||||
})
|
||||
|
||||
it('streams tool calls with re-stringified arguments', async () => {
|
||||
const server = await mockServer([{ events: toolEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
|
||||
const result = await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'weather?' }] }],
|
||||
tools: [{
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
parameters: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
}],
|
||||
})
|
||||
expect(result.finish).toEqual({ kind: 'tool-calls' })
|
||||
const call = result.message.content.find(block => block.type === 'tool-call')
|
||||
expect(call).toMatchObject({ name: 'get_weather', arguments: '{"city":"Paris"}' })
|
||||
})
|
||||
|
||||
it('maps reasoning_content streams to reasoning blocks', async () => {
|
||||
const server = await mockServer([{ events: thinkingEvents }])
|
||||
const ctx = await harness(server.url, { reasoning: 'high' })
|
||||
|
||||
const result = await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'think' }] }],
|
||||
})
|
||||
expect(result.message.content).toEqual([
|
||||
{ type: 'reasoning', text: 'pondering' },
|
||||
{ type: 'text', text: 'answer' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sends DeepSeek thinking fields when reasoning is configured', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { reasoning: 'xhigh' })
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
thinking: { type: 'enabled' },
|
||||
reasoning_effort: 'max', // xhigh maps to max via thinkingLevelMap
|
||||
})
|
||||
})
|
||||
|
||||
it('disables thinking for reasoning: off', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { reasoning: 'off' })
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests[0]).toMatchObject({ thinking: { type: 'disabled' } })
|
||||
})
|
||||
|
||||
it('injects stop sequences through onPayload', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], stop: ['END'] })
|
||||
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
|
||||
})
|
||||
|
||||
it('preserves per-tool strict exactly through onPayload', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
tools: [
|
||||
{ name: 'strict_true', description: 'true', parameters: {}, strict: true },
|
||||
{ name: 'strict_false', description: 'false', parameters: {}, strict: false },
|
||||
{ name: 'strict_omitted', description: 'omitted', parameters: {} },
|
||||
],
|
||||
})
|
||||
|
||||
const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] }
|
||||
expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([
|
||||
['strict_true', true],
|
||||
['strict_false', false],
|
||||
['strict_omitted', undefined],
|
||||
])
|
||||
expect('strict' in request.tools[2]!.function).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves raw replayed tool-call arguments in the provider payload', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('broken'), name: 'f', arguments: '{broken' }],
|
||||
}],
|
||||
})
|
||||
|
||||
const request = server.requests[0] as { messages: { role: string; tool_calls?: { id: string; function: { arguments: string } }[] }[] }
|
||||
const assistant = request.messages.find(message => message.role === 'assistant')
|
||||
expect(assistant?.tool_calls?.[0]?.function.arguments).toBe('{broken')
|
||||
})
|
||||
|
||||
it('maps HTTP errors to error finish chunks (pi-ai in-stream style)', async () => {
|
||||
const server = await mockServer([{
|
||||
status: 401,
|
||||
body: JSON.stringify({ error: { message: 'bad key' } }),
|
||||
}])
|
||||
const ctx = await harness(server.url)
|
||||
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.finish).toMatchObject({ kind: 'error', code: 'AUTH' })
|
||||
expect((result.finish as { message: string }).message).toMatch(/bad key|401/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[400, 'INVALID_REQUEST'],
|
||||
[429, 'RATE_LIMIT'],
|
||||
[500, 'SERVER'],
|
||||
] as const)('maps HTTP %s to stable error code %s', async (status, code) => {
|
||||
const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }])
|
||||
const ctx = await harness(server.url)
|
||||
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.finish).toMatchObject({ kind: 'error', code })
|
||||
})
|
||||
|
||||
it('rejects prefill with UNSUPPORTED', async () => {
|
||||
const ctx = await harness('http://127.0.0.1:1')
|
||||
await expect(assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
prefill: [{ type: 'text', text: 'Sure' }],
|
||||
})).rejects.toThrow(LlmError)
|
||||
})
|
||||
|
||||
it('registers/unregisters models on the llm service (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmPiAi, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
it('throws a clear error when no API key is available', async () => {
|
||||
const previous = process.env.DEEPSEEK_API_KEY
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmPiAi, {})).rejects.toThrow(/an API key is required/)
|
||||
} finally {
|
||||
if (previous !== undefined) process.env.DEEPSEEK_API_KEY = previous
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('option spreads and env fallbacks', () => {
|
||||
it('forwards temperature, maxTokens, and signal', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
const controller = new AbortController()
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
temperature: 0.5,
|
||||
maxTokens: 40,
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(server.requests[0]).toMatchObject({ temperature: 0.5, max_tokens: 40 })
|
||||
})
|
||||
|
||||
it('falls back to DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL env vars', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, { models: ['deepseek-v4-flash'] })
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to the public base URL without config or env', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {})
|
||||
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildModel', () => {
|
||||
it('builds a DeepSeek-compat openai-completions model descriptor', () => {
|
||||
const model = buildModel('deepseek-v4-pro', { apiKey: 'k', baseURL: 'http://x', reasoning: 'high' })
|
||||
expect(model).toMatchObject({
|
||||
id: 'deepseek-v4-pro',
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
baseUrl: 'http://x',
|
||||
reasoning: true,
|
||||
compat: { thinkingFormat: 'deepseek', requiresReasoningContentOnAssistantMessages: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps reasoning true even for off (pi-ai gates the thinking field on it)', () => {
|
||||
// 'off' yields {thinking: {type: 'disabled'}} on the wire — pi-ai only
|
||||
// emits the field at all when model.reasoning is true.
|
||||
expect(buildModel('m', { apiKey: 'k', baseURL: 'http://x', reasoning: 'off' }).reasoning).toBe(true)
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding', () => {
|
||||
expect(new PiAiAdapter({ apiKey: 'k', baseURL: 'http://x' })).toBeInstanceOf(PiAiAdapter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes', () => {
|
||||
it('defaults omitted reasoning config to thinking ENABLED (provider default)', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url) // no reasoning key at all
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
const request = server.requests[0] as Record<string, unknown>
|
||||
expect(request.thinking).toEqual({ type: 'enabled' })
|
||||
expect('reasoning_effort' in request).toBe(false)
|
||||
})
|
||||
|
||||
it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'weather?' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'I should check.' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const request = server.requests[0] as { messages: { role: string; reasoning_content?: string }[] }
|
||||
const assistant = request.messages.find(message => message.role === 'assistant')
|
||||
expect(assistant?.reasoning_content).toBe('I should check.')
|
||||
})
|
||||
|
||||
it('aborts the upstream request when the consumer stops streaming early', async () => {
|
||||
// Slow server: write one chunk, then hold the connection open and record
|
||||
// whether the socket closes (the adapter must cancel on early break).
|
||||
let socketClosed = false
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
request.on('data', () => undefined)
|
||||
request.on('end', () => {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.write(`data: ${textEvents[0]}\n\n`)
|
||||
response.write(`data: ${textEvents[1]}\n\n`)
|
||||
// never finish; rely on client abort
|
||||
request.socket.on('close', () => { socketClosed = true })
|
||||
})
|
||||
})
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('no port')
|
||||
const ctx = await harness(`http://127.0.0.1:${address.port}`)
|
||||
|
||||
for await (const chunk of ctx.llm.stream({ model: 'deepseek-v4-flash', messages: [] })) {
|
||||
if (chunk.type === 'text-delta') break // stop early mid-stream
|
||||
}
|
||||
// The finally-abort must reach the server as a closed socket.
|
||||
await vi.waitFor(() => { expect(socketClosed).toBe(true) }, { timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: abort wiring', () => {
|
||||
it('honors a pre-aborted caller signal', async () => {
|
||||
const ctx = await harness('http://127.0.0.1:1')
|
||||
const controller = new AbortController()
|
||||
controller.abort('already cancelled')
|
||||
// pi-ai surfaces the abort as an in-stream error event → aborted finish.
|
||||
const result = await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.finish.kind).toBe('aborted')
|
||||
})
|
||||
|
||||
it('propagates a mid-stream caller abort to the upstream request', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
const controller = new AbortController()
|
||||
const pending = assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
signal: controller.signal,
|
||||
})
|
||||
controller.abort()
|
||||
const result = await pending
|
||||
// Either the abort lands before any chunk (aborted) or after the tiny
|
||||
// mock stream finished (stop) — both are valid races; never a hang.
|
||||
expect(['aborted', 'stop']).toContain(result.finish.kind)
|
||||
})
|
||||
})
|
||||
26
packages/llm/llm-pi-ai/tests/assemble.ts
Normal file
26
packages/llm/llm-pi-ai/tests/assemble.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
|
||||
* the assembled message + usage + finish reason. This exercises the same
|
||||
* streaming path production uses (the loop), rather than a service-level
|
||||
* one-shot convenience method.
|
||||
*/
|
||||
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export interface AssembledResult {
|
||||
message: Message
|
||||
usage?: TokenUsage
|
||||
finish: FinishReason
|
||||
}
|
||||
|
||||
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
return {
|
||||
message: assembler.message(),
|
||||
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
|
||||
finish: assembler.finish,
|
||||
}
|
||||
}
|
||||
336
packages/llm/llm-pi-ai/tests/convert.spec.ts
Normal file
336
packages/llm/llm-pi-ai/tests/convert.spec.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
|
||||
import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
|
||||
function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function assistant(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
usage: usage(),
|
||||
stopReason: 'stop',
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function* feed(...events: AssistantMessageEvent[]): AsyncGenerator<AssistantMessageEvent> {
|
||||
for (const event of events) yield event
|
||||
}
|
||||
|
||||
async function collect(stream: AsyncIterable<StreamChunk>): Promise<StreamChunk[]> {
|
||||
const out: StreamChunk[] = []
|
||||
for await (const chunk of stream) out.push(chunk)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('toPiContext', () => {
|
||||
it('maps system prompt, user text, and tools', () => {
|
||||
const context = toPiContext({
|
||||
model: 'deepseek-v4-flash',
|
||||
system: 'be helpful',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
||||
tools: [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }],
|
||||
})
|
||||
expect(context.systemPrompt).toBe('be helpful')
|
||||
expect(context.messages).toEqual([{ role: 'user', content: 'hi', timestamp: 0 }])
|
||||
expect(context.tools).toEqual([
|
||||
{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } },
|
||||
])
|
||||
})
|
||||
|
||||
it('omits empty tools and absent system prompt', () => {
|
||||
const context = toPiContext({ model: 'm', messages: [], tools: [] })
|
||||
expect(context.systemPrompt).toBeUndefined()
|
||||
expect(context.tools).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps assistant text/reasoning/tool-call blocks', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'hmm' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const message = context.messages[0] as AssistantMessage
|
||||
expect(message.role).toBe('assistant')
|
||||
expect(message.stopReason).toBe('toolUse')
|
||||
expect(message.content).toEqual([
|
||||
// thinkingSignature names the replay field — DeepSeek's passback rule.
|
||||
{ type: 'thinking', thinking: 'hmm', thinkingSignature: 'reasoning_content' },
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'toolCall', id: 'c1', name: 'f', arguments: { a: 1 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks tool-call-free assistant messages with stopReason stop', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
|
||||
})
|
||||
expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop')
|
||||
})
|
||||
|
||||
it('parses malformed tool-call arguments to {}', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{broken' }],
|
||||
}],
|
||||
})
|
||||
const message = context.messages[0] as AssistantMessage
|
||||
expect(message.content[0]).toEqual({ type: 'toolCall', id: 'c1', name: 'f', arguments: {} })
|
||||
})
|
||||
|
||||
it('parses non-object argument JSON (arrays, scalars) to {}', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '[1,2]' }],
|
||||
}],
|
||||
})
|
||||
expect((context.messages[0] as AssistantMessage).content[0]).toMatchObject({ arguments: {} })
|
||||
})
|
||||
|
||||
it('recovers toolName for tool results from the preceding assistant call', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{}' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(context.messages[1]).toEqual({
|
||||
role: 'toolResult',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'get_weather',
|
||||
content: [{ type: 'text', text: 'Sunny' }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('labels unmatched tool results with toolName unknown and keeps isError', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('zz'), content: [], isError: true }],
|
||||
}],
|
||||
})
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'toolResult',
|
||||
toolName: 'unknown',
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: '(no output)' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('splits mixed user text + tool results and folds history system messages', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'system', content: [{ type: 'text', text: 'rule' }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'note' },
|
||||
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
|
||||
})
|
||||
|
||||
it('skips image and unknown blocks in assistant content', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'image', url: 'data:,x' },
|
||||
{ type: 'text', text: 'visible' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
expect((context.messages[0] as AssistantMessage).content).toEqual([{ type: 'text', text: 'visible' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStreamChunks', () => {
|
||||
const partialWithToolCall = assistant({
|
||||
content: [{ type: 'toolCall', id: 'call-1', name: 'f', arguments: {} }],
|
||||
})
|
||||
|
||||
it('maps text events to text blocks', async () => {
|
||||
const done = assistant({ content: [{ type: 'text', text: 'hi' }], usage: usage(3, 2) })
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'start', partial: assistant() },
|
||||
{ type: 'text_start', contentIndex: 0, partial: assistant() },
|
||||
{ type: 'text_delta', contentIndex: 0, delta: 'hi', partial: assistant() },
|
||||
{ type: 'text_end', contentIndex: 0, content: 'hi', partial: assistant() },
|
||||
{ type: 'done', reason: 'stop', message: done },
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
|
||||
{ type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps thinking events to reasoning blocks', async () => {
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'thinking_start', contentIndex: 0, partial: assistant() },
|
||||
{ type: 'thinking_delta', contentIndex: 0, delta: 'mull', partial: assistant() },
|
||||
{ type: 'thinking_end', contentIndex: 0, content: 'mull', partial: assistant() },
|
||||
{ type: 'done', reason: 'stop', message: assistant() },
|
||||
)))
|
||||
expect(chunks.slice(0, 3)).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'mull' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'mull' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps tool-call events, re-stringifying parsed arguments', async () => {
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'toolcall_start', contentIndex: 0, partial: partialWithToolCall },
|
||||
{ type: 'toolcall_delta', contentIndex: 0, delta: '{"a"', partial: partialWithToolCall },
|
||||
{ type: 'toolcall_delta', contentIndex: 0, delta: ':1}', partial: partialWithToolCall },
|
||||
{
|
||||
type: 'toolcall_end',
|
||||
contentIndex: 0,
|
||||
toolCall: { type: 'toolCall', id: 'call-1', name: 'f', arguments: { a: 1 } },
|
||||
partial: partialWithToolCall,
|
||||
},
|
||||
{ type: 'done', reason: 'toolUse', message: assistant({ stopReason: 'toolUse' }) },
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: '{"a"' },
|
||||
{ type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: ':1}' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: 'call-1', name: 'f', arguments: '{"a":1}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates toolcall_start with a missing partial entry', async () => {
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'toolcall_start', contentIndex: 0, partial: assistant() },
|
||||
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
|
||||
{ type: 'done', reason: 'stop', message: assistant() },
|
||||
)))
|
||||
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
|
||||
})
|
||||
|
||||
it('maps error events to error finish chunks (in-stream error style)', async () => {
|
||||
const error = assistant({ stopReason: 'error', errorMessage: 'boom', usage: usage(1, 0) })
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'error', reason: 'error', error },
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } },
|
||||
{ type: 'finish', reason: { kind: 'error', message: 'boom', code: 'PI_AI_ERROR' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps aborted error events to aborted finish', async () => {
|
||||
const error = assistant({ stopReason: 'aborted' })
|
||||
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } })
|
||||
})
|
||||
|
||||
it('rejects a stream that ends without done or error', async () => {
|
||||
await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() }))))
|
||||
.rejects.toThrow(/without done\/error/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapStopReason / mapUsage', () => {
|
||||
it.each([
|
||||
['stop', { kind: 'stop' }],
|
||||
['length', { kind: 'max-tokens' }],
|
||||
['toolUse', { kind: 'tool-calls' }],
|
||||
['aborted', { kind: 'aborted' }],
|
||||
] as const)('maps %s', (stopReason, expected) => {
|
||||
expect(mapStopReason(assistant({ stopReason }))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('defaults the error message when pi-ai omits it', () => {
|
||||
expect(mapStopReason(assistant({ stopReason: 'error' })))
|
||||
.toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' })
|
||||
})
|
||||
|
||||
it('maps routable HTTP-ish error messages to stable codes', () => {
|
||||
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' })))
|
||||
.toMatchObject({ kind: 'error', code: 'AUTH' })
|
||||
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' })))
|
||||
.toMatchObject({ kind: 'error', code: 'RATE_LIMIT' })
|
||||
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' })))
|
||||
.toMatchObject({ kind: 'error', code: 'SERVER' })
|
||||
})
|
||||
|
||||
it('maps cache fields only when nonzero', () => {
|
||||
expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 8,
|
||||
cacheWriteTokens: 2,
|
||||
})
|
||||
expect(mapUsage(usage(10, 5))).toEqual({ inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStreamChunks edge branches', () => {
|
||||
it('omits the name field for tool calls whose partial carried an empty name', async () => {
|
||||
const blank = assistant({ content: [{ type: 'toolCall', id: 'x', name: '', arguments: {} }] })
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'toolcall_start', contentIndex: 0, partial: blank },
|
||||
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: blank },
|
||||
{ type: 'done', reason: 'stop', message: assistant() },
|
||||
)))
|
||||
expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'x', argumentsDelta: '{}' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStreamChunks defensive branches', () => {
|
||||
it('tolerates a toolcall_delta with no preceding toolcall_start', async () => {
|
||||
const chunks = await collect(toStreamChunks(feed(
|
||||
{ type: 'toolcall_delta', contentIndex: 0, delta: '{}', partial: assistant() },
|
||||
{ type: 'done', reason: 'stop', message: assistant() },
|
||||
)))
|
||||
expect(chunks[0]).toEqual({ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' })
|
||||
})
|
||||
})
|
||||
24
packages/llm/llm-pi-ai/tsconfig.json
Normal file
24
packages/llm/llm-pi-ai/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
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).
|
||||
@@ -22,9 +22,11 @@
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Incremental chunk-to-message assembler. This is the single canonical assembly
|
||||
* algorithm used by both the agent loop and the LLM service convenience views.
|
||||
* 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'
|
||||
import { assertNever } from './never'
|
||||
import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types'
|
||||
import { CallId } from './brand.ts'
|
||||
import { assertNever } from './never.ts'
|
||||
import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types'
|
||||
|
||||
interface PartialBlock {
|
||||
blockType: string
|
||||
@@ -23,9 +24,8 @@ interface PartialBlock {
|
||||
* Incrementally assembles raw {@link StreamChunk}s into complete
|
||||
* {@link ContentBlock}s and a final assistant {@link Message}.
|
||||
*
|
||||
* This is the single shared assembly implementation: the agent loop feeds it
|
||||
* while logging raw chunks for replay fidelity, and `LlmService.generate()` /
|
||||
* `streamBlocks()` use it to offer assembled views of the same stream.
|
||||
* 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
|
||||
@@ -34,7 +34,6 @@ interface PartialBlock {
|
||||
export class BlockAssembler {
|
||||
private partials = new Map<number, PartialBlock>()
|
||||
private order: number[] = []
|
||||
private flushed = 0
|
||||
private _usage: TokenUsage | undefined
|
||||
private _finish: FinishReason | undefined
|
||||
|
||||
@@ -129,44 +128,6 @@ export class BlockAssembler {
|
||||
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming flush: returns (once) every block that is complete AND has no
|
||||
* incomplete block before it in stream order. Call after each `push()`;
|
||||
* blocks come out strictly in stream order, so a streaming consumer sees
|
||||
* exactly the sequence `blocks()` would produce.
|
||||
*/
|
||||
flushReady(): ContentBlock[] {
|
||||
const ready: ContentBlock[] = []
|
||||
while (this.flushed < this.order.length) {
|
||||
const index = this.order[this.flushed]
|
||||
/* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists in a non-empty array */
|
||||
if (index === undefined) break
|
||||
const partial = this.mustGet(index)
|
||||
if (!partial.block) break
|
||||
ready.push(partial.block)
|
||||
this.flushed += 1
|
||||
}
|
||||
return ready
|
||||
}
|
||||
|
||||
/**
|
||||
* End-of-stream flush: returns (once) all not-yet-flushed blocks, in stream
|
||||
* order, assembling still-open ones from their deltas (delta-only
|
||||
* protocols). After this, `flushReady()` + `flushRemaining()` together have
|
||||
* yielded exactly `blocks()`.
|
||||
*/
|
||||
flushRemaining(): ContentBlock[] {
|
||||
const remaining: ContentBlock[] = []
|
||||
while (this.flushed < this.order.length) {
|
||||
const index = this.order[this.flushed]
|
||||
/* v8 ignore next 3 -- noUncheckedIndexedAccess guard: loop condition guarantees index exists */
|
||||
if (index === undefined) break
|
||||
remaining.push(this.assemble(this.mustGet(index), index))
|
||||
this.flushed += 1
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
get usage(): TokenUsage | undefined {
|
||||
return this._usage
|
||||
}
|
||||
@@ -179,13 +140,4 @@ export class BlockAssembler {
|
||||
message(): Message {
|
||||
return { role: 'assistant', content: this.blocks() }
|
||||
}
|
||||
|
||||
/** The assembled non-streaming result. */
|
||||
result(): GenerateResult {
|
||||
return {
|
||||
message: this.message(),
|
||||
...this._usage !== undefined ? { usage: this._usage } : {},
|
||||
finish: this.finish,
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* LLM service: adapter registry with waterfall-interceptable streaming and
|
||||
* non-streaming call surfaces. Exports the `LlmService` default, the abstract
|
||||
* `LlmAdapter` for provider backends, and `BlockAssembler` for chunk assembly.
|
||||
* 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 { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types'
|
||||
import { BlockAssembler } from './assembler'
|
||||
import type { GenerateOptions, StreamChunk } from './types'
|
||||
import { HarnessError } from './error'
|
||||
|
||||
export * from './brand'
|
||||
@@ -23,12 +22,13 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/** Waterfall around every streaming model call (retry, caching, routing). */
|
||||
/**
|
||||
* 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>
|
||||
/** Waterfall around every non-streaming model call. */
|
||||
'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise<GenerateResult>): Promise<GenerateResult>
|
||||
/** An adapter was registered or unregistered. */
|
||||
'llm/adapter-change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ export abstract class LlmAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* The abstract `llm` service: an adapter registry plus streaming /
|
||||
* non-streaming call surfaces, both interceptable via waterfall events.
|
||||
* 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>()
|
||||
@@ -76,8 +76,7 @@ export class LlmService extends Service {
|
||||
/**
|
||||
* Register an adapter for the given model names. Throws `LlmError` with code
|
||||
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
|
||||
* Emits `llm/adapter-change` on registration and disposal. Disposed with the
|
||||
* fiber.
|
||||
* Disposed with the fiber.
|
||||
*/
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
@@ -87,17 +86,9 @@ export class LlmService extends Service {
|
||||
}
|
||||
}
|
||||
for (const model of models) this.adapters.set(model, adapter)
|
||||
// Yield the rollback BEFORE emitting the change event: a generator effect
|
||||
// collects each yielded disposer before running the next step, so a
|
||||
// throwing `llm/adapter-change` listener rolls the mutation back instead
|
||||
// of leaking the entry (which would wedge the duplicate check until
|
||||
// restart). The duplicate throws above fire before any mutation, so they
|
||||
// correctly leak nothing.
|
||||
yield () => {
|
||||
for (const model of models) this.adapters.delete(model)
|
||||
this.ctx.emit('llm/adapter-change')
|
||||
}
|
||||
this.ctx.emit('llm/adapter-change')
|
||||
}.bind(this), 'llm.registerAdapter()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
@@ -125,36 +116,6 @@ export class LlmService extends Service {
|
||||
return this.adapter(options.model).stream(options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as completed content blocks — a convenience view
|
||||
* for consumers that don't care about token-level deltas. Blocks are
|
||||
* yielded strictly in stream order as soon as they (and everything before
|
||||
* them) complete; blocks left open at end of stream (delta-only protocols)
|
||||
* are assembled and flushed last, so the sequence always equals
|
||||
* `generate()`'s `message.content`.
|
||||
*/
|
||||
async * streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock> {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of this.stream(options)) {
|
||||
assembler.push(chunk)
|
||||
yield * assembler.flushReady()
|
||||
}
|
||||
yield * assembler.flushRemaining()
|
||||
}
|
||||
|
||||
/**
|
||||
* One model call, fully assembled (drains the chunk stream). Dispatches
|
||||
* through the `llm/generate` waterfall (and the inner stream through
|
||||
* `llm/stream`). Same completion guarantees as `streamBlocks()`.
|
||||
*/
|
||||
generate(options: GenerateOptions): Promise<GenerateResult> {
|
||||
return this.ctx.waterfall(this, 'llm/generate', options, async () => {
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of this.stream(options)) assembler.push(chunk)
|
||||
return assembler.result()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default LlmService
|
||||
@@ -193,10 +193,3 @@ export interface GenerateOptions {
|
||||
stop?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Non-streaming result, assembled from the chunk stream. */
|
||||
export interface GenerateResult {
|
||||
message: Message
|
||||
usage?: TokenUsage
|
||||
finish: FinishReason
|
||||
}
|
||||
@@ -81,36 +81,6 @@ describe('BlockAssembler', () => {
|
||||
expect(() => assembler.blocks()).toThrow('BlockAssembler invariant violated')
|
||||
})
|
||||
|
||||
it('assembles open blocks at end of stream via flushRemaining', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'open' })
|
||||
assembler.push({ type: 'reasoning-delta', index: 1, text: 'thinking' })
|
||||
|
||||
// flushReady returns nothing because index 0 is incomplete and blocking
|
||||
const ready = assembler.flushReady()
|
||||
expect(ready).toEqual([])
|
||||
|
||||
// flushRemaining assembles everything still open
|
||||
const remaining = assembler.flushRemaining()
|
||||
expect(remaining).toEqual([
|
||||
{ type: 'text', text: 'open' },
|
||||
{ type: 'reasoning', text: 'thinking' },
|
||||
])
|
||||
|
||||
// blocks() now matches the flushed view
|
||||
expect(assembler.blocks()).toEqual(remaining)
|
||||
})
|
||||
|
||||
it('result() omits usage key when no usage was received', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'text-delta', index: 0, text: 'msg' })
|
||||
const result = assembler.result()
|
||||
expect(result.message).toBeDefined()
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
// usage should NOT be present on the object at all
|
||||
expect('usage' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores duplicate block-start for the same index', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
|
||||
@@ -142,13 +112,11 @@ describe('BlockAssembler', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('includes usage in result() when usage was received', () => {
|
||||
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 } })
|
||||
const result = assembler.result()
|
||||
expect(result.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
|
||||
expect('usage' in result).toBe(true)
|
||||
expect(assembler.usage).toEqual({ inputTokens: 5, outputTokens: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,25 +140,25 @@ describe('BlockAssembler regressions (property-test findings)', () => {
|
||||
// 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 streaming and one-shot assembly stay identical.
|
||||
// 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 flushed = []
|
||||
const closed = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
const block = streaming.push(chunk)
|
||||
if (block) closed.push(block)
|
||||
}
|
||||
flushed.push(...streaming.flushRemaining())
|
||||
|
||||
const oneShot = new BlockAssembler()
|
||||
for (const chunk of chunks) oneShot.push(chunk)
|
||||
|
||||
expect(flushed).toEqual([{ type: 'reasoning', text: 'first' }])
|
||||
expect(closed).toEqual([{ type: 'reasoning', text: 'first' }])
|
||||
expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }])
|
||||
expect(flushed).toEqual(oneShot.blocks())
|
||||
expect(closed).toEqual(oneShot.blocks())
|
||||
})
|
||||
|
||||
it('push returns undefined for a duplicate block-end (it closed nothing)', () => {
|
||||
@@ -4,13 +4,13 @@
|
||||
* 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 and LlmService rely on.
|
||||
* 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 { ContentBlock, StreamChunk } 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.
|
||||
@@ -55,38 +55,6 @@ function feed(chunks: StreamChunk[]): BlockAssembler {
|
||||
}
|
||||
|
||||
describe('BlockAssembler properties', () => {
|
||||
it('flushReady() ++ flushRemaining() === blocks(), in order', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const streaming = new BlockAssembler()
|
||||
const flushed: ContentBlock[] = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
}
|
||||
flushed.push(...streaming.flushRemaining())
|
||||
|
||||
const oneShot = feed(chunks).blocks()
|
||||
expect(flushed).toEqual(oneShot)
|
||||
}))
|
||||
})
|
||||
|
||||
it('streamBlocks-style flush never yields a block before an earlier open one', () => {
|
||||
// flushReady is strict-order: once it stops at an open index, no later
|
||||
// index may be emitted until that one closes. We assert the flushed prefix
|
||||
// is always a prefix of the final blocks() order.
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const streaming = new BlockAssembler()
|
||||
const flushed: ContentBlock[] = []
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
flushed.push(...streaming.flushReady())
|
||||
}
|
||||
const finalSoFar = streaming.blocks()
|
||||
// Everything flushed mid-stream is a prefix of the full ordered blocks.
|
||||
expect(finalSoFar.slice(0, flushed.length)).toEqual(flushed)
|
||||
}))
|
||||
})
|
||||
|
||||
it('partials map size never exceeds the number of distinct indices seen', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const distinct = new Set<number>()
|
||||
@@ -131,20 +99,4 @@ describe('BlockAssembler properties', () => {
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('streaming and one-shot assembly agree on usage and finish', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
// Streaming consumer: push + flush as it goes.
|
||||
const streaming = new BlockAssembler()
|
||||
for (const chunk of chunks) {
|
||||
streaming.push(chunk)
|
||||
streaming.flushReady()
|
||||
}
|
||||
streaming.flushRemaining()
|
||||
// One-shot consumer: push all, then read.
|
||||
const oneShot = feed(chunks)
|
||||
expect(streaming.usage).toEqual(oneShot.usage)
|
||||
expect(streaming.finish).toEqual(oneShot.finish)
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -19,24 +19,22 @@ const SCRIPT: StreamChunk[] = [
|
||||
]
|
||||
|
||||
describe('LlmService', () => {
|
||||
it('routes stream() to the registered adapter and generate() assembles it', async () => {
|
||||
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).toHaveLength(3)
|
||||
|
||||
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(chunks).toEqual(SCRIPT)
|
||||
})
|
||||
|
||||
it('throws NO_ADAPTER for unregistered models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.llm.generate({ model: 'nope', messages: [] })).rejects.toThrow('no adapter registered')
|
||||
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 () => {
|
||||
@@ -71,21 +69,6 @@ describe('LlmService', () => {
|
||||
expect(chunks[0]).toMatchObject({ index: 99 })
|
||||
})
|
||||
|
||||
it('lets llm/generate waterfall listeners intercept and transform the result', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT))
|
||||
|
||||
ctx.on('llm/generate', async function (_options, next) {
|
||||
const result = await next()
|
||||
return { ...result, finish: { kind: 'max-tokens' } as const }
|
||||
})
|
||||
|
||||
const result = await ctx.llm.generate({ model: 'test-model', messages: [] })
|
||||
expect(result.finish).toEqual({ kind: 'max-tokens' })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
|
||||
})
|
||||
|
||||
it('creates LlmError with a code for programmatic handling', () => {
|
||||
const err = new LlmError('something went wrong', 'CUSTOM_CODE')
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
@@ -116,20 +99,13 @@ describe('LlmService', () => {
|
||||
expect(isHarnessError('nope')).toBe(false)
|
||||
})
|
||||
|
||||
it('disposes adapter registration on adapter-change event emission', async () => {
|
||||
it('removes the adapter when the returned disposer is called', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const changes: string[][] = []
|
||||
ctx.on('llm/adapter-change', () => {
|
||||
changes.push([...ctx.llm.models()])
|
||||
})
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(changes).toEqual([['m1']])
|
||||
|
||||
expect(ctx.llm.models()).toEqual(['m1'])
|
||||
dispose()
|
||||
expect(changes).toEqual([['m1'], []])
|
||||
expect(ctx.llm.models()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -147,25 +123,19 @@ describe('LlmService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the adapter entry when an adapter-change listener throws (P1-1)', async () => {
|
||||
it('re-registers a model after its prior registration is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
// A change listener that throws on the FIRST emit only.
|
||||
let threw = false
|
||||
ctx.on('llm/adapter-change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the mutation back, not leak it.
|
||||
expect(() => ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))).toThrow('boom change listener')
|
||||
expect(ctx.llm.models()).toEqual([]) // entry rolled back, not leaked
|
||||
|
||||
// A subsequent listener-free register of the SAME model succeeds and
|
||||
// contributes exactly once (the duplicate check is not wedged).
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Branded (nominal) ID types.
|
||||
*
|
||||
* A brand makes structurally-identical strings non-interchangeable at the
|
||||
* type level: an `AgentId` cannot be passed where a `CallId` is expected,
|
||||
* even though both are strings at runtime. Construction goes through the
|
||||
* per-type factory (a plain cast inside — zero runtime cost); comparison,
|
||||
* logging, and serialization all behave as ordinary strings.
|
||||
*
|
||||
* Policy: core packages brand the IDs they own — `CallId` here (tool-call
|
||||
* correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding
|
||||
* is for IDs that cross package boundaries and could plausibly be confused;
|
||||
* not every string needs a brand.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/brand
|
||||
*/
|
||||
|
||||
declare const BRAND: unique symbol
|
||||
|
||||
/** A string carrying a compile-time-only brand `B`. */
|
||||
export type Branded<B extends string> = string & { readonly [BRAND]: B }
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../vendor/cosmokit" },
|
||||
{ "path": "../../vendor/cordis" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user