Merge branch 'master' into fix-update-builderror

This commit is contained in:
Tianyi Cui
2026-07-26 02:39:35 +08:00
committed by GitHub
123 changed files with 2381 additions and 1485 deletions

View File

@@ -41,8 +41,11 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",

View File

@@ -0,0 +1,234 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as Retry from '../src/index.ts'
let context: Context | undefined
const servers: MockLlmServer[] = []
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
await Promise.all(servers.splice(0).map(server => server.close()))
})
async function start(
sequence: readonly MockLlmBehavior[],
options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
): Promise<MockLlmServer> {
const server = await startMockLlmServer({ sequence, ...options })
servers.push(server)
return server
}
async function harness(
baseURL: string,
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'mock-key',
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
})
await ctx.plugin(Retry, {
maxTransientRetries: 2,
initialDelayMs: options.initialDelayMs ?? 10,
maxDelayMs: options.initialDelayMs ?? 10,
jitterRatio: 0,
})
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle') return
dispose()
resolve()
})
})
}
function sendAndWait(ctx: Context, agent: Agent): Promise<void> {
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'recover through the provider boundary' }])
return idle
}
function finalAssistantText(agent: Agent): string | undefined {
const message = agent.session.deriveMessages().at(-1)
if (message?.role !== 'assistant') return undefined
return message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
async function unusedPort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
const port = (server.address() as AddressInfo).port
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
return port
}
describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
it('recovers from a true refused connection after the endpoint starts during backoff', async () => {
const port = await unusedPort()
context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 })
const agent = context.agentLoop.create(SessionId('wire-refused'), {
provider: 'deepseek',
model: 'mock-model',
})
let recoveryServer: Promise<MockLlmServer> | undefined
context.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'llm/retry' || event.data.retry !== 1) return
recoveryServer = start(['success'], { port, apiKey: 'mock-key', successText: 'connected after retry' })
})
await sendAndWait(context, agent)
const server = await recoveryServer
expect(server).toBeDefined()
expect(server?.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start').map(event => event.data.step))
.toEqual([1, 2])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('connected after retry')
})
it.each([
['stream_disconnect', 0] as const,
['partial_disconnect', 2] as const,
])('retries %s without committing failed chunks', async (behavior, failedChunkCount) => {
const server = await start([behavior, 'success'], {
apiKey: 'mock-key',
partialText: 'discard me',
chunkSize: 100,
disconnectDelayMs: 20,
successText: 'recovered response',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
)).toHaveLength(failedChunkCount)
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('recovered response')
})
it('retries a wire-valid content-less completion without committing an empty message', async () => {
const server = await start(['empty', 'success'], {
apiKey: 'mock-key',
successText: 'recovered from empty',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-empty'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['EMPTY_RESPONSE'])
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
})
expect(finalAssistantText(agent)).toBe('recovered from empty')
})
it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => {
const server = await start(['partial_eof', 'success'], {
apiKey: 'mock-key',
partialText: 'discarded clean eof',
chunkSize: 100,
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-partial-eof'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
)).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'STREAM_CLOSED' } } },
})
})
it('turns a stalled body into TIMEOUT and succeeds on the next request', async () => {
const server = await start(['stall', 'success'], {
apiKey: 'mock-key',
successText: 'recovered after timeout',
})
context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 })
const agent = context.agentLoop.create(SessionId('wire-stall'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success'])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TIMEOUT'])
expect(finalAssistantText(agent)).toBe('recovered after timeout')
})
it('stops after the configured transport retry budget is exhausted', async () => {
const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], {
apiKey: 'mock-key',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-exhausted'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'TRANSPORT' } } },
})
})
})

View File

@@ -6,4 +6,4 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -29,7 +29,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
Design: [plan-specific collaboration state](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
## Model Experience

View File

@@ -15,8 +15,7 @@
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Notes:
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
* Agent Note:
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode

View File

@@ -8,6 +8,7 @@ Packages that exist to serve development, testing, and the examples rather than
| `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) |
| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) |
| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
| `llm-mock-server/` | Scriptable OpenAI-compatible HTTP/SSE fault server + CLI for LLM recovery tests | (standalone server and test library) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate, while `llm-mock-server` drives real provider adapters through deterministic HTTP/SSE faults. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

View File

@@ -0,0 +1,84 @@
# `@deepseek-ai/dsh-llm-mock-server`
A scriptable OpenAI-compatible HTTP/SSE server for exercising real LLM adapters, the agent loop, and recovery policy without a provider key. It accepts `POST /chat/completions` and `POST /v1/chat/completions`; each accepted request consumes one configured behavior in arrival order. Invalid methods, paths, bearer tokens, and JSON do not consume the script.
The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, the accepted Node timer bound, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections.
## Standalone use
Run the source entry from this repository:
```sh
pnpm run mock:llm -- \
--port 8000 \
--api-key mock-key \
--sequence partial_disconnect,success \
--partial-text "discard this half"
```
Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base:
```sh
DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \
DEEPSEEK_API_KEY=mock-key \
pnpm run demo:headless "test provider recovery"
```
The built package also exposes `dsh-llm-mock-server`. Stdout is JSONL: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior.
## Behavior script
`--sequence` is a comma-separated FIFO. Exhaustion returns a structured HTTP 500; `--repeat-last` explicitly reuses the last entry.
| Behavior | Wire result |
|---|---|
| `connection_reset` | Destroy the socket before HTTP headers |
| `stream_disconnect` | Send SSE headers, then reset before the first event |
| `partial_disconnect` | Send text deltas, then reset the socket |
| `stall` | Send SSE headers and remain idle until client/server cancellation |
| `empty` | Send a valid content-less stop and `[DONE]` |
| `empty_body` / `stream_eof` / `partial_eof` | End cleanly without the required `[DONE]` boundary |
| `malformed_json` / `malformed_event` | Send invalid SSE JSON or an invalid provider chunk shape |
| `rate_limit` / `server_error` / `service_unavailable` | Return retry-oriented 429/500/503 JSON errors |
| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | Return terminal or separately recovered provider errors |
| `success` / `slow_success` / `reasoning_success` | Stream a complete text response, optionally delayed or preceded by reasoning |
| `tool_call_success` / `max_tokens` | Complete with a tool call or `length` finish |
| `wrong_content_type` | Send a valid SSE body under `application/json` |
| `random` | Select a concrete request behavior from weighted seeded randomness |
`connection_refused` is CLI-only and must be the first entry. It delays binding a caller-specified nonzero port, so requests during `--listen-delay-ms` receive a real TCP refusal; the remaining entries begin after the listener starts.
## Random mode
Use a repeating `random` entry for an open-ended mixed run:
```sh
pnpm run mock:llm -- \
--port 8000 \
--sequence random \
--repeat-last \
--seed 42 \
--random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5'
```
Omitting `--seed` generates one and prints it in the `ready` record. `--random-weights` accepts non-negative relative `behavior=weight` entries and requires at least one positive concrete behavior. The exported default is a success-heavy stress profile containing reset, disconnect, partial output, empty completion, stall, 429/5xx, clean truncation, and malformed JSON; it is test pressure, not an estimate of production incident frequency. `connection_refused` is excluded because a bound request handler cannot produce a true refusal.
When random weights include `stall`, configure the client under test with a short stream-idle timeout so the scenario terminates promptly.
## Timing and content controls
The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer <token>`; omission accepts any token.
## Model Experience
None, as this test server substitutes provider wire behavior without invoking a real model.
#### KV Cache effect
None; requests terminate locally and never reach a provider cache.
## Known Limitations and Deferred Work
- **Random weights model test pressure, not production incidence** — callers that want an environment-specific distribution must provide measured weights and record the emitted seed.
- **Request scripts are arrival-ordered** — concurrent callers share one cursor, so deterministic per-session fault assignment requires separate server instances.
- **True connection refusal is a listener lifecycle phase** — the CLI delay must overlap the client attempt; request-level random selection can only reset an accepted connection.

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-llm-mock-server",
"description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-llm-mock-server": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Standalone process wrapper for the scriptable mock LLM server.
* @module @deepseek-ai/dsh-llm-mock-server/bin
*/
import { setTimeout as delay } from 'node:timers/promises'
import { MOCK_LLM_CLI_USAGE, parseMockLlmCliArgs } from './cli.ts'
import { startMockLlmServer } from './index.ts'
/* v8 ignore start -- thin process/signal glue; parser and server behavior are covered directly */
try {
const parsed = parseMockLlmCliArgs(process.argv.slice(2))
if (parsed.kind === 'help') {
process.stdout.write(MOCK_LLM_CLI_USAGE)
} else {
const { server: serverOptions, listenDelayMs, startsUnavailable } = parsed.config
const host = serverOptions.host ?? '127.0.0.1'
const port = serverOptions.port ?? 8_000
if (startsUnavailable) {
process.stdout.write(`${JSON.stringify({
type: 'unavailable',
baseURL: `http://${host}:${port}/v1`,
listenDelayMs,
})}\n`)
await delay(listenDelayMs)
}
const server = await startMockLlmServer({
...serverOptions,
onEvent: (event) => { process.stdout.write(`${JSON.stringify(event)}\n`) },
})
process.stdout.write(`${JSON.stringify({
type: 'ready',
baseURL: `${server.baseURL}/v1`,
randomSeed: server.randomSeed,
})}\n`)
let closing = false
const close = (code: number): void => {
if (closing) return
closing = true
void server.close().finally(() => { process.exit(code) })
}
process.on('SIGINT', () => { close(130) })
process.on('SIGTERM', () => { close(143) })
}
} catch (error: unknown) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${MOCK_LLM_CLI_USAGE}`)
process.exitCode = 1
}
/* v8 ignore stop */

View File

@@ -0,0 +1,222 @@
/**
* Dependency-free CLI parsing for the standalone mock LLM server.
* @module @deepseek-ai/dsh-llm-mock-server/cli
*/
import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts'
import type {
ConcreteMockLlmBehavior,
MockLlmBehavior,
MockLlmRandomWeights,
MockLlmServerOptions,
} from './index.ts'
/** Listener lifecycle behavior understood only by the standalone CLI. */
export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused'
/** Parsed CLI configuration, including a pre-listen unavailable interval. */
export interface MockLlmCliConfig {
/** Server options after removing the lifecycle-only `connection_refused` entry. */
readonly server: MockLlmServerOptions
/** Delay before binding the model port; an integer from zero through the Node timer maximum. */
readonly listenDelayMs: number
/** Whether the original sequence requested a true pre-listen refusal phase. */
readonly startsUnavailable: boolean
}
/** Result of parsing `dsh-llm-mock-server` arguments. */
export type MockLlmCliParseResult =
| { readonly kind: 'help' }
| { readonly kind: 'run'; readonly config: MockLlmCliConfig }
const BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS)
const DEFAULT_LISTEN_DELAY_MS = 750
/** Command usage written for `--help` and invalid arguments. */
export const MOCK_LLM_CLI_USAGE = `Usage: dsh-llm-mock-server [options]
Required:
--sequence <a,b,...> Ordered behaviors; connection_refused is allowed first
Listener:
--host <host> Default 127.0.0.1
--port <port> Default 8000; required and nonzero for connection_refused
--api-key <token> Validate exact Bearer token when present
--listen-delay-ms <ms> Unavailable interval (default 750 with connection_refused)
--repeat-last Repeat the final request behavior after exhaustion
--seed <uint32> Reproduce random selections
--random-weights <a=n,...> Relative weights for concrete behaviors
Response:
--success-text <text>
--partial-text <text>
--reasoning-text <text>
--chunk-size <count>
--chunk-delay-ms <ms>
--disconnect-delay-ms <ms>
--retry-after-ms <ms>
--request-id <id>
--tool-name <name>
--tool-arguments <json>
Other:
--help
`
function optionValue(argv: readonly string[], index: number, option: string): string {
const value = argv[index + 1]
if (value === undefined || value.startsWith('--')) {
throw new Error(`dsh-llm-mock-server: ${option} requires a value`)
}
return value
}
function numberValue(option: string, value: string): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`)
return parsed
}
function boundedIntegerValue(option: string, value: string, min: number, max: number): number {
const parsed = numberValue(option, value)
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`dsh-llm-mock-server: ${option} must be an integer between ${min} and ${max}`)
}
return parsed
}
function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } {
const entries = raw.split(',').map(entry => entry.trim())
if (entries.some(entry => entry.length === 0)) {
throw new Error('dsh-llm-mock-server: --sequence must contain non-empty comma-separated behaviors')
}
const startsUnavailable = entries[0] === CONNECTION_REFUSED_BEHAVIOR
if (entries.slice(1).includes(CONNECTION_REFUSED_BEHAVIOR)) {
throw new Error('dsh-llm-mock-server: connection_refused is allowed only as the first behavior')
}
const requestEntries = startsUnavailable ? entries.slice(1) : entries
if (requestEntries.length === 0) {
throw new Error('dsh-llm-mock-server: connection_refused must be followed by a request behavior')
}
for (const entry of requestEntries) {
if (!BEHAVIORS.has(entry)) throw new Error(`dsh-llm-mock-server: unknown behavior ${JSON.stringify(entry)}`)
}
return { startsUnavailable, sequence: requestEntries as MockLlmBehavior[] }
}
function parseRandomWeights(raw: string): MockLlmRandomWeights {
const weights: MockLlmRandomWeights = {}
for (const entry of raw.split(',')) {
const [behavior, rawWeight, ...extra] = entry.split('=')
if (behavior === undefined || behavior === '' || rawWeight === undefined || rawWeight === '' || extra.length > 0) {
throw new Error('dsh-llm-mock-server: --random-weights expects behavior=weight comma-separated entries')
}
if (!BEHAVIORS.has(behavior) || behavior === 'random') {
throw new Error(`dsh-llm-mock-server: random weight requires a concrete behavior, got ${JSON.stringify(behavior)}`)
}
if (Object.hasOwn(weights, behavior)) {
throw new Error(`dsh-llm-mock-server: duplicate random weight for ${JSON.stringify(behavior)}`)
}
weights[behavior as ConcreteMockLlmBehavior] = numberValue('--random-weights', rawWeight)
}
return weights
}
/**
* Parse standalone server arguments without starting a process or listener.
* @param argv - arguments after the executable name.
* @returns help or validated run configuration.
*/
export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult {
if (argv.includes('--help')) return { kind: 'help' }
let sequenceRaw: string | undefined
let host: string | undefined
let port = 8_000
let apiKey: string | undefined
let listenDelayMs: number | undefined
let repeatLast = false
let randomSeed: number | undefined
let randomWeights: MockLlmRandomWeights | undefined
let successText: string | undefined
let partialText: string | undefined
let reasoningText: string | undefined
let chunkSize: number | undefined
let chunkDelayMs: number | undefined
let disconnectDelayMs: number | undefined
let retryAfterMs: number | undefined
let requestId: string | undefined
let toolName: string | undefined
let toolArguments: string | undefined
for (let index = 0; index < argv.length; index += 1) {
const option = argv[index] as string
if (option === '--repeat-last') {
repeatLast = true
continue
}
const value = optionValue(argv, index, option)
index += 1
switch (option) {
case '--sequence': sequenceRaw = value; break
case '--host': host = value; break
case '--port': port = numberValue(option, value); break
case '--api-key': apiKey = value; break
case '--listen-delay-ms':
listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS)
break
case '--seed': randomSeed = numberValue(option, value); break
case '--random-weights': randomWeights = parseRandomWeights(value); break
case '--success-text': successText = value; break
case '--partial-text': partialText = value; break
case '--reasoning-text': reasoningText = value; break
case '--chunk-size': chunkSize = numberValue(option, value); break
case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break
case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break
case '--retry-after-ms': retryAfterMs = numberValue(option, value); break
case '--request-id': requestId = value; break
case '--tool-name': toolName = value; break
case '--tool-arguments': toolArguments = value; break
default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`)
}
}
if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required')
const parsedSequence = parseSequence(sequenceRaw)
if (parsedSequence.startsUnavailable && port === 0) {
throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port')
}
if (!parsedSequence.startsUnavailable && listenDelayMs !== undefined) {
throw new Error('dsh-llm-mock-server: --listen-delay-ms requires connection_refused first in --sequence')
}
if (!parsedSequence.sequence.includes('random') && (randomSeed !== undefined || randomWeights !== undefined)) {
throw new Error('dsh-llm-mock-server: --seed and --random-weights require random in --sequence')
}
return {
kind: 'run',
config: {
server: {
sequence: parsedSequence.sequence,
port,
repeatLast,
...randomSeed === undefined ? {} : { randomSeed },
...randomWeights === undefined ? {} : { randomWeights },
...host === undefined ? {} : { host },
...apiKey === undefined ? {} : { apiKey },
...successText === undefined ? {} : { successText },
...partialText === undefined ? {} : { partialText },
...reasoningText === undefined ? {} : { reasoningText },
...chunkSize === undefined ? {} : { chunkSize },
...chunkDelayMs === undefined ? {} : { chunkDelayMs },
...disconnectDelayMs === undefined ? {} : { disconnectDelayMs },
...retryAfterMs === undefined ? {} : { retryAfterMs },
...requestId === undefined ? {} : { requestId },
...toolName === undefined ? {} : { toolName },
...toolArguments === undefined ? {} : { toolArguments },
},
listenDelayMs: parsedSequence.startsUnavailable ? listenDelayMs ?? DEFAULT_LISTEN_DELAY_MS : 0,
startsUnavailable: parsedSequence.startsUnavailable,
},
}
}

View File

@@ -0,0 +1,738 @@
/**
* Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and
* semantic-empty LLM recovery tests. Each accepted chat-completions request
* consumes one behavior; the server never retries or interprets harness policy.
*
* @module @deepseek-ai/dsh-llm-mock-server
*/
import { createServer } from 'node:http'
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'
import { randomBytes } from 'node:crypto'
import { isIP, type AddressInfo } from 'node:net'
import { setTimeout as delay } from 'node:timers/promises'
/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
export const MOCK_LLM_BEHAVIORS = [
'connection_reset',
'stream_disconnect',
'empty',
'empty_body',
'stream_eof',
'partial_eof',
'partial_disconnect',
'stall',
'malformed_json',
'malformed_event',
'wrong_content_type',
'rate_limit',
'server_error',
'service_unavailable',
'auth_error',
'invalid_request',
'context_overflow',
'quota_exceeded',
'success',
'reasoning_success',
'tool_call_success',
'max_tokens',
'slow_success',
'random',
] as const
/** One scripted mock behavior name; `random` selects a concrete behavior per request. */
export type MockLlmBehavior = typeof MOCK_LLM_BEHAVIORS[number]
/** One concrete request behavior after resolving a `random` script entry. */
export type ConcreteMockLlmBehavior = Exclude<MockLlmBehavior, 'random'>
/** Relative non-negative weights for random request behavior selection. */
export type MockLlmRandomWeights = Partial<Record<ConcreteMockLlmBehavior, number>>
/**
* Default stress profile for `random`. Weights are configurable test pressure,
* not a claim about production incident frequency.
*/
export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights> = Object.freeze({
success: 48,
slow_success: 10,
max_tokens: 2,
connection_reset: 5,
stream_disconnect: 5,
partial_disconnect: 10,
empty: 5,
stall: 2,
rate_limit: 5,
server_error: 4,
service_unavailable: 2,
partial_eof: 1,
malformed_json: 1,
})
/** Largest millisecond delay accepted by Node timers without truncation. */
export const MAX_MOCK_LLM_TIMER_DELAY_MS = 2_147_483_647
/** How one accepted request ended at the mock boundary. */
export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error'
/** Immutable telemetry emitted when a request starts or reaches an outcome. */
export type MockLlmServerEvent =
| {
readonly type: 'request'
readonly attempt: number
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
readonly path: string
}
| {
readonly type: 'result'
readonly attempt: number
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
readonly outcome: MockLlmRequestOutcome
readonly chunksSent: number
}
/** Captured wire request and its final server-side outcome. */
export interface MockLlmRequestRecord {
/** One-based accepted chat-completions request number. */
readonly attempt: number
/** Script entry consumed for this request before random resolution. */
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted'
/** Concrete behavior selected for this request, or exhaustion after the configured script. */
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted'
/** Original request path, including a `/v1` prefix when the client supplied one. */
readonly path: string
/** Detached request headers. */
readonly headers: Readonly<IncomingHttpHeaders>
/** Parsed JSON request body. */
readonly body: unknown
/** Number of SSE `data:` events handed to Node before the outcome. */
chunksSent: number
/** Final server-side outcome; absent while a stalled request remains open. */
outcome?: MockLlmRequestOutcome
}
/** Configuration for one mock server instance. */
export interface MockLlmServerOptions {
/** Loopback host by default. */
readonly host?: string
/** TCP port; zero requests an OS-assigned port. */
readonly port?: number
/** Optional exact bearer token; omission accepts any authorization header. */
readonly apiKey?: string
/** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */
readonly sequence: readonly MockLlmBehavior[]
/** Reuse the final behavior after the sequence is consumed. */
readonly repeatLast?: boolean
/** Optional deterministic unsigned 32-bit seed; omission generates and exposes one. */
readonly randomSeed?: number
/** Relative weights used whenever a script entry is `random`. */
readonly randomWeights?: Readonly<MockLlmRandomWeights>
/** Complete text returned by success-shaped behaviors. */
readonly successText?: string
/** Text emitted before partial EOF/reset behaviors terminate. */
readonly partialText?: string
/** Reasoning text emitted by `reasoning_success`. */
readonly reasoningText?: string
/** Unicode code-point count per text or reasoning SSE delta. */
readonly chunkSize?: number
/** Inter-chunk delay for `slow_success`, in milliseconds. */
readonly chunkDelayMs?: number
/** Delay after headers/deltas before a forced disconnect, in milliseconds. */
readonly disconnectDelayMs?: number
/** Provider retry delay; the wire `Retry-After` value rounds up to whole seconds. */
readonly retryAfterMs?: number
/** Optional provider request id returned on HTTP failures. */
readonly requestId?: string
/** Tool name emitted by `tool_call_success`. */
readonly toolName?: string
/** Raw JSON arguments emitted by `tool_call_success`. */
readonly toolArguments?: string
/** Optional observer for JSONL CLI telemetry; observer failures never affect wire behavior. */
readonly onEvent?: (event: MockLlmServerEvent) => void
}
/** Running mock server and captured request state. */
export interface MockLlmServer {
/** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */
readonly baseURL: string
/** Actual bound port, including an OS-assigned value. */
readonly port: number
/** Seed used for random behavior selection, including the generated default. */
readonly randomSeed: number
/** Live request records in arrival order. */
readonly requests: readonly MockLlmRequestRecord[]
/** Stop accepting requests and force-close stalled/streaming connections; idempotent. */
close(): Promise<void>
}
interface ResolvedOptions {
readonly host: string
readonly port: number
readonly apiKey?: string
readonly sequence: readonly MockLlmBehavior[]
readonly lastBehavior: MockLlmBehavior
readonly repeatLast: boolean
readonly randomSeed: number
readonly randomWeights: readonly (readonly [ConcreteMockLlmBehavior, number])[]
readonly successText: string
readonly partialText: string
readonly reasoningText: string
readonly chunkSize: number
readonly chunkDelayMs: number
readonly disconnectDelayMs: number
readonly retryAfterMs: number
readonly requestId?: string
readonly toolName: string
readonly toolArguments: string
readonly onEvent?: (event: MockLlmServerEvent) => void
}
const DEFAULT_SUCCESS_TEXT = 'mock response recovered'
const DEFAULT_PARTIAL_TEXT = 'discarded partial response'
const DEFAULT_REASONING_TEXT = 'mock reasoning'
const CONCRETE_BEHAVIORS = new Set<string>(MOCK_LLM_BEHAVIORS.filter(behavior => behavior !== 'random'))
function boundedInteger(name: string, value: number, min: number, max: number): number {
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`llm-mock-server: ${name} must be an integer between ${min} and ${max}`)
}
return value
}
function resolveOptions(options: MockLlmServerOptions): ResolvedOptions {
const host = options.host ?? '127.0.0.1'
const port = boundedInteger('port', options.port ?? 0, 0, 65_535)
const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER)
const chunkDelayMs = boundedInteger(
'chunkDelayMs',
options.chunkDelayMs ?? 25,
0,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const disconnectDelayMs = boundedInteger(
'disconnectDelayMs',
options.disconnectDelayMs ?? 10,
0,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const retryAfterMs = boundedInteger(
'retryAfterMs',
options.retryAfterMs ?? 1_000,
1,
MAX_MOCK_LLM_TIMER_DELAY_MS,
)
const randomSeed = boundedInteger(
'randomSeed',
options.randomSeed ?? randomBytes(4).readUInt32LE(0),
0,
0xffff_ffff,
)
const successText = options.successText ?? DEFAULT_SUCCESS_TEXT
const partialText = options.partialText ?? DEFAULT_PARTIAL_TEXT
const reasoningText = options.reasoningText ?? DEFAULT_REASONING_TEXT
const toolName = options.toolName ?? 'mock_tool'
const toolArguments = options.toolArguments ?? '{"value":"mock"}'
if (host.length === 0) throw new Error('llm-mock-server: host must not be empty')
if (options.sequence.length === 0) throw new Error('llm-mock-server: sequence must not be empty')
const lastBehavior = options.sequence.reduce((_previous, behavior) => behavior)
if (options.apiKey === '') throw new Error('llm-mock-server: apiKey must not be empty')
if (successText.length === 0) throw new Error('llm-mock-server: successText must not be empty')
if (partialText.length === 0) throw new Error('llm-mock-server: partialText must not be empty')
if (reasoningText.length === 0) throw new Error('llm-mock-server: reasoningText must not be empty')
if (toolName.length === 0) throw new Error('llm-mock-server: toolName must not be empty')
if (options.requestId === '') throw new Error('llm-mock-server: requestId must not be empty')
try {
JSON.parse(toolArguments)
} catch {
throw new Error('llm-mock-server: toolArguments must be valid JSON')
}
const configuredWeights = options.randomWeights ?? DEFAULT_MOCK_LLM_RANDOM_WEIGHTS
const randomWeights: Array<readonly [ConcreteMockLlmBehavior, number]> = []
for (const [behavior, weight] of Object.entries(configuredWeights)) {
if (!CONCRETE_BEHAVIORS.has(behavior)) {
throw new Error(`llm-mock-server: randomWeights contains unknown concrete behavior ${JSON.stringify(behavior)}`)
}
if (!Number.isFinite(weight) || weight < 0) {
throw new Error(`llm-mock-server: random weight for ${behavior} must be a non-negative finite number`)
}
if (weight > 0) randomWeights.push([behavior as ConcreteMockLlmBehavior, weight])
}
if (randomWeights.length === 0) {
throw new Error('llm-mock-server: randomWeights must contain at least one positive weight')
}
return {
host,
port,
...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
sequence: [...options.sequence],
lastBehavior,
repeatLast: options.repeatLast ?? false,
randomSeed,
randomWeights,
successText,
partialText,
reasoningText,
chunkSize,
chunkDelayMs,
disconnectDelayMs,
retryAfterMs,
...options.requestId === undefined ? {} : { requestId: options.requestId },
toolName,
toolArguments,
...options.onEvent === undefined ? {} : { onEvent: options.onEvent },
}
}
function emit(options: ResolvedOptions, event: MockLlmServerEvent): void {
try {
options.onEvent?.(Object.freeze(event))
} catch (_telemetryObserverFailure) {
// Test telemetry is observational; a broken observer cannot change provider wire behavior.
}
}
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array))
const body = Buffer.concat(chunks).toString('utf8')
return body.length === 0 ? undefined : JSON.parse(body)
}
function splitText(text: string, size: number): string[] {
const points = Array.from(text)
const chunks: string[] = []
for (let index = 0; index < points.length; index += size) chunks.push(points.slice(index, index + size).join(''))
return chunks
}
function openSse(response: ServerResponse, contentType = 'text/event-stream; charset=utf-8'): void {
response.writeHead(200, {
'content-type': contentType,
'cache-control': 'no-cache',
'connection': 'keep-alive',
})
response.flushHeaders()
}
function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payload: unknown): void {
response.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`)
record.chunksSent += 1
}
function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void {
writeSse(record, response, '[DONE]')
}
function finishRecord(
options: ResolvedOptions,
record: MockLlmRequestRecord,
outcome: MockLlmRequestOutcome,
): void {
if (record.outcome !== undefined) return
record.outcome = outcome
emit(options, {
type: 'result',
attempt: record.attempt,
scriptBehavior: record.scriptBehavior,
behavior: record.behavior,
outcome,
chunksSent: record.chunksSent,
})
}
function httpError(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
status: number,
message: string,
code: string,
type = 'mock_error',
): void {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (record.behavior === 'rate_limit') {
headers['retry-after'] = String(Math.ceil(options.retryAfterMs / 1_000))
}
if (options.requestId !== undefined) headers['x-request-id'] = options.requestId
response.writeHead(status, headers)
response.end(JSON.stringify({ error: { message, type, code } }))
finishRecord(options, record, 'completed')
}
function terminalChunk(reason: string, outputTokens: number): unknown {
return {
choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }],
usage: { prompt_tokens: 3, completion_tokens: outputTokens },
}
}
async function pause(milliseconds: number, response: ServerResponse): Promise<boolean> {
if (milliseconds === 0) return !response.destroyed
const controller = new AbortController()
const stop = (): void => { controller.abort() }
response.once('close', stop)
try {
await delay(milliseconds, undefined, { signal: controller.signal })
return true
} catch (_responseClosed) {
// The timer only receives this response-owned abort signal; closing the response cancels its wait.
return false
} finally {
response.off('close', stop)
}
}
async function streamText(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
text: string,
delayMs: number,
): Promise<boolean> {
for (const chunk of splitText(text, options.chunkSize)) {
writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] })
if (!await pause(delayMs, response)) return false
}
return true
}
async function completeText(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
reason: 'stop' | 'length',
delayMs: number,
): Promise<void> {
if (!await streamText(options, record, response, options.successText, delayMs)) {
finishRecord(options, record, 'client_closed')
return
}
writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
}
async function disconnect(
options: ResolvedOptions,
record: MockLlmRequestRecord,
response: ServerResponse,
): Promise<void> {
if (!await pause(options.disconnectDelayMs, response)) {
finishRecord(options, record, 'client_closed')
return
}
finishRecord(options, record, 'reset')
response.destroy()
}
function toolCallChunks(options: ResolvedOptions): readonly unknown[] {
const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2))
return [
{
choices: [{
index: 0,
delta: {
tool_calls: [{
index: 0,
id: 'mock-call-1',
type: 'function',
function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) },
}],
},
finish_reason: null,
}],
},
{
choices: [{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] },
finish_reason: null,
}],
},
]
}
async function runBehavior(
options: ResolvedOptions,
record: MockLlmRequestRecord,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
switch (record.behavior) {
case 'script_exhausted':
httpError(options, record, response, 500, 'mock script exhausted', 'MOCK_SCRIPT_EXHAUSTED')
return
case 'connection_reset':
finishRecord(options, record, 'reset')
request.socket.destroy()
return
case 'stream_disconnect':
openSse(response)
await disconnect(options, record, response)
return
case 'empty':
openSse(response)
writeSse(record, response, terminalChunk('stop', 0))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'empty_body':
openSse(response)
response.end()
finishRecord(options, record, 'completed')
return
case 'stream_eof':
openSse(response)
writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
response.end()
finishRecord(options, record, 'completed')
return
case 'partial_eof':
openSse(response)
await streamText(options, record, response, options.partialText, 0)
response.end()
finishRecord(options, record, 'completed')
return
case 'partial_disconnect':
openSse(response)
if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return
await disconnect(options, record, response)
return
case 'stall':
openSse(response)
finishRecord(options, record, 'stalled')
return
case 'malformed_json':
openSse(response)
writeSse(record, response, '{not-json')
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'malformed_event':
openSse(response)
writeSse(record, response, { choices: [null] })
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'wrong_content_type':
openSse(response, 'application/json')
await completeText(options, record, response, 'stop', 0)
return
case 'rate_limit':
httpError(options, record, response, 429, 'mock rate limit', 'rate_limit')
return
case 'server_error':
httpError(options, record, response, 500, 'mock server error', 'server_error')
return
case 'service_unavailable':
httpError(options, record, response, 503, 'mock service unavailable', 'service_unavailable')
return
case 'auth_error':
httpError(options, record, response, 401, 'mock authentication failed', 'invalid_api_key')
return
case 'invalid_request':
httpError(options, record, response, 400, 'mock invalid request', 'invalid_request')
return
case 'context_overflow':
httpError(
options,
record,
response,
400,
'mock input exceeds the model context window',
'context_length_exceeded',
'invalid_request_error',
)
return
case 'quota_exceeded':
httpError(options, record, response, 429, 'mock insufficient quota', 'insufficient_quota')
return
case 'success':
openSse(response)
await completeText(options, record, response, 'stop', 0)
return
case 'reasoning_success':
openSse(response)
for (const chunk of splitText(options.reasoningText, options.chunkSize)) {
writeSse(record, response, {
choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }],
})
}
await completeText(options, record, response, 'stop', 0)
return
case 'tool_call_success':
openSse(response)
for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk)
writeSse(record, response, terminalChunk('tool_calls', 2))
writeDone(record, response)
response.end()
finishRecord(options, record, 'completed')
return
case 'max_tokens':
openSse(response)
await completeText(options, record, response, 'length', 0)
return
case 'slow_success':
openSse(response)
await completeText(options, record, response, 'stop', options.chunkDelayMs)
return
}
}
function seededRandom(seed: number): () => number {
let state = seed
return () => {
state = (state + 0x6d2b_79f5) >>> 0
let mixed = state
mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1)
mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61)
return ((mixed ^ mixed >>> 14) >>> 0) / 0x1_0000_0000
}
}
function chooseRandomBehavior(
weights: readonly (readonly [ConcreteMockLlmBehavior, number])[],
random: () => number,
): ConcreteMockLlmBehavior {
const total = weights.reduce((sum, entry) => sum + entry[1], 0)
let draw = random() * total
for (const [behavior, weight] of weights) {
if (draw < weight) return behavior
draw -= weight
}
// Floating-point subtraction can only leave a rounding residue at the upper boundary.
/* v8 ignore next -- seededRandom is strictly less than one; this guards floating-point residue only */
return (weights.at(-1) as readonly [ConcreteMockLlmBehavior, number])[0]
}
/**
* Start a local chat-completions server that consumes one configured behavior
* per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script;
* invalid routes, methods, authorization, and JSON receive ordinary 4xx
* responses. Closing the handle terminates stalled connections.
*
* @param options - listener, script, response content, timing, and telemetry options.
* @returns the listening handle after the port is bound.
*/
export async function startMockLlmServer(options: MockLlmServerOptions): Promise<MockLlmServer> {
const resolved = resolveOptions(options)
const requests: MockLlmRequestRecord[] = []
const random = seededRandom(resolved.randomSeed)
let cursor = 0
const selectBehavior = (): {
scriptBehavior: MockLlmBehavior | 'script_exhausted'
behavior: ConcreteMockLlmBehavior | 'script_exhausted'
} => {
const selected = resolved.sequence[cursor]
cursor += 1
const scriptBehavior = selected
?? (resolved.repeatLast ? resolved.lastBehavior : 'script_exhausted')
return {
scriptBehavior,
behavior: scriptBehavior === 'random'
? chooseRandomBehavior(resolved.randomWeights, random)
: scriptBehavior,
}
}
const handle = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
/* v8 ignore next -- node:http server requests always carry a URL despite the shared optional type */
const path = new URL(request.url ?? '/', 'http://mock.invalid').pathname
if (request.method !== 'POST') {
response.writeHead(405, { allow: 'POST' }).end()
return
}
if (!path.endsWith('/chat/completions')) {
response.writeHead(404).end()
return
}
if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) {
response.writeHead(401, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } }))
return
}
let body: unknown
try {
body = await readJsonBody(request)
} catch {
response.writeHead(400, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'request body must be valid JSON', code: 'invalid_json' } }))
return
}
const selected = selectBehavior()
const record: MockLlmRequestRecord = {
attempt: requests.length + 1,
scriptBehavior: selected.scriptBehavior,
behavior: selected.behavior,
path,
headers: { ...request.headers },
body,
chunksSent: 0,
}
requests.push(record)
response.once('close', () => {
if (!response.writableFinished && record.outcome === undefined) {
finishRecord(resolved, record, 'client_closed')
}
})
emit(resolved, {
type: 'request',
attempt: record.attempt,
scriptBehavior: record.scriptBehavior,
behavior: record.behavior,
path,
})
await runBehavior(resolved, record, request, response)
}
const server = createServer((request, response) => {
/* v8 ignore start -- last-resort containment for Node response failures after validated test inputs */
handle(request, response).catch((error: unknown) => {
const record = requests.at(-1)
if (record !== undefined) finishRecord(resolved, record, 'server_error')
if (response.headersSent) {
response.destroy(error instanceof Error ? error : new Error(String(error)))
return
}
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'mock server handler failed', code: 'MOCK_HANDLER_FAILED' } }))
})
/* v8 ignore stop */
})
let closing: Promise<void> | undefined
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
server.close(() => { resolveClose() })
server.closeAllConnections()
}))
await new Promise<void>((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(resolved.port, resolved.host, () => {
server.off('error', rejectListen)
resolveListen()
})
})
const address = server.address() as AddressInfo
const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host
return {
baseURL: `http://${advertisedHost}:${address.port}`,
port: address.port,
randomSeed: resolved.randomSeed,
requests,
close,
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`.
* @module @deepseek-ai/dsh-llm-mock-server/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-llm-mock-server'
/** Cordis companion plugin name. */
export const name = 'llm-mock-server-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this standalone test server owns no Cordis event stream or shared data;
* its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import {
MOCK_LLM_CLI_USAGE,
parseMockLlmCliArgs,
} from '../src/cli.ts'
describe('mock LLM server CLI parser', () => {
it('returns help without requiring a sequence', () => {
expect(parseMockLlmCliArgs(['--help'])).toEqual({ kind: 'help' })
expect(MOCK_LLM_CLI_USAGE).toContain('--sequence')
})
it('parses every request and listener option', () => {
expect(parseMockLlmCliArgs([
'--sequence', 'connection_refused,partial_disconnect,success',
'--host', 'localhost',
'--port', '9010',
'--api-key', 'mock-key',
'--listen-delay-ms', '100',
'--repeat-last',
'--success-text', 'done',
'--partial-text', 'half',
'--reasoning-text', 'think',
'--chunk-size', '2',
'--chunk-delay-ms', '3',
'--disconnect-delay-ms', '4',
'--retry-after-ms', '5000',
'--request-id', 'request-1',
'--tool-name', 'lookup',
'--tool-arguments', '{"id":1}',
])).toEqual({
kind: 'run',
config: {
startsUnavailable: true,
listenDelayMs: 100,
server: {
sequence: ['partial_disconnect', 'success'],
host: 'localhost',
port: 9010,
apiKey: 'mock-key',
repeatLast: true,
successText: 'done',
partialText: 'half',
reasoningText: 'think',
chunkSize: 2,
chunkDelayMs: 3,
disconnectDelayMs: 4,
retryAfterMs: 5000,
requestId: 'request-1',
toolName: 'lookup',
toolArguments: '{"id":1}',
},
},
})
})
it('uses standalone defaults for an ordinary sequence', () => {
expect(parseMockLlmCliArgs(['--sequence', 'success'])).toEqual({
kind: 'run',
config: {
startsUnavailable: false,
listenDelayMs: 0,
server: {
sequence: ['success'],
port: 8000,
repeatLast: false,
},
},
})
})
it('uses the default unavailable interval', () => {
const result = parseMockLlmCliArgs(['--sequence', 'connection_refused,success', '--port', '8001'])
expect(result).toMatchObject({
kind: 'run',
config: { startsUnavailable: true, listenDelayMs: 750 },
})
})
it('parses a reproducible weighted random profile', () => {
expect(parseMockLlmCliArgs([
'--sequence', 'random',
'--repeat-last',
'--seed', '42',
'--random-weights', 'success=8,partial_disconnect=2',
])).toEqual({
kind: 'run',
config: {
startsUnavailable: false,
listenDelayMs: 0,
server: {
sequence: ['random'],
port: 8000,
repeatLast: true,
randomSeed: 42,
randomWeights: { success: 8, partial_disconnect: 2 },
},
},
})
})
it.each([
[[], /--sequence is required/],
[['--wat'], /requires a value/],
[['--wat', 'x'], /unknown option/],
[['--port', 'NaN', '--sequence', 'success'], /finite number/],
[['--sequence', 'success,'], /non-empty/],
[['--sequence', 'success,connection_refused'], /only as the first/],
[['--sequence', 'connection_refused'], /must be followed/],
[['--sequence', 'unknown'], /unknown behavior/],
[['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/],
[['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/],
[['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/],
[['--sequence', 'success', '--seed', '1'], /require random/],
[['--sequence', 'random', '--random-weights', 'success'], /expects behavior=weight/],
[['--sequence', 'random', '--random-weights', 'random=1'], /concrete behavior/],
[['--sequence', 'random', '--random-weights', 'success=1,success=2'], /duplicate/],
[['--sequence', 'random', '--random-weights', 'success=nope'], /finite number/],
])('rejects invalid argv %#', (argv, expected) => {
expect(() => parseMockLlmCliArgs(argv)).toThrow(expected)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as MockServerInvariant from '../src/invariant.ts'
describe('mock LLM server invariant companion', () => {
it('registers its explained empty runtime invariant', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(MockServerInvariant)
expect(() => {
ctx.invariants.register('@deepseek-ai/dsh-llm-mock-server', () => {})
}).toThrow(/already registered/)
await fiber.dispose()
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,355 @@
import { request } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts'
import { startMockLlmServer } from '../src/index.ts'
const running: MockLlmServer[] = []
afterEach(async () => {
await Promise.all(running.splice(0).map(server => server.close()))
})
async function start(
sequence: readonly MockLlmBehavior[],
options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
): Promise<MockLlmServer> {
const server = await startMockLlmServer({ sequence, ...options })
running.push(server)
return server
}
function chat(
server: MockLlmServer,
options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {},
): Promise<Response> {
return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` },
},
body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }),
...options.signal === undefined ? {} : { signal: options.signal },
})
}
function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise<void> {
return new Promise((resolve, reject) => {
const outgoing = request(`${server.baseURL}/v1/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
}, (response) => {
response.once('error', reject)
response.once('end', resolve)
response.resume()
})
outgoing.once('error', reject)
for (const chunk of chunks) outgoing.write(chunk)
outgoing.end()
})
}
describe('mock LLM server wire behaviors', () => {
it('streams a complete text response and captures the request', async () => {
const events: MockLlmServerEvent[] = []
const server = await start(['success'], {
apiKey: 'mock-key',
successText: 'recovered',
chunkSize: 3,
onEvent: (event) => { events.push(event) },
})
const response = await chat(server, { key: 'mock-key' })
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/event-stream')
expect(body).toContain('"content":"rec"')
expect(body).toContain('"content":"ove"')
expect(body).toContain('"content":"red"')
expect(body).toContain('"finish_reason":"stop"')
expect(body).toContain('data: [DONE]')
expect(server.requests).toEqual([expect.objectContaining({
attempt: 1,
behavior: 'success',
path: '/v1/chat/completions',
body: { model: 'mock', messages: [], stream: true },
chunksSent: 5,
outcome: 'completed',
})])
expect(events).toEqual([
{
type: 'request',
attempt: 1,
scriptBehavior: 'success',
behavior: 'success',
path: '/v1/chat/completions',
},
{
type: 'result',
attempt: 1,
scriptBehavior: 'success',
behavior: 'success',
outcome: 'completed',
chunksSent: 5,
},
])
})
it('supports root paths and intentionally ignores telemetry observer failures', async () => {
const server = await start(['empty'], {
onEvent() {
throw new Error('observer failed')
},
})
const response = await chat(server, { path: '/chat/completions' })
expect(response.status).toBe(200)
expect(await response.text()).toContain('data: [DONE]')
expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' })
})
it.each([
['empty_body', 0, ''] as const,
['stream_eof', 1, '"role":"assistant"'] as const,
['partial_eof', 1, 'discarded partial response'] as const,
['malformed_json', 2, 'data: {not-json'] as const,
['malformed_event', 2, '"choices":[null]'] as const,
])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => {
const server = await start([behavior], { chunkSize: 100 })
const response = await chat(server)
const body = await response.text()
expect(response.status).toBe(200)
expect(body).toContain(marker)
if (behavior !== 'malformed_json' && behavior !== 'malformed_event') {
expect(body).not.toContain('[DONE]')
}
expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' })
})
it.each([
['connection_reset', false] as const,
['stream_disconnect', true] as const,
['partial_disconnect', true] as const,
])('forces the %s transport boundary', async (behavior, receivesHeaders) => {
const server = await start([behavior], { disconnectDelayMs: 20, partialText: 'half' })
let headersReceived = false
await expect((async () => {
const response = await chat(server)
headersReceived = true
await response.text()
})()).rejects.toThrow()
expect(headersReceived).toBe(receivesHeaders)
expect(server.requests[0]).toMatchObject({
behavior,
chunksSent: behavior === 'partial_disconnect' ? 1 : 0,
outcome: 'reset',
})
})
it('holds a stalled stream until the client aborts and server close remains idempotent', async () => {
const server = await start(['stall'])
const controller = new AbortController()
const response = await chat(server, { signal: controller.signal })
expect(response.status).toBe(200)
expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' })
controller.abort()
await expect(response.text()).rejects.toThrow()
await server.close()
await server.close()
})
it.each([
['slow_success', 100] as const,
['stream_disconnect', 100] as const,
['partial_disconnect', 100] as const,
])('records a client that closes during %s', async (behavior, delayMs) => {
const events: MockLlmServerEvent[] = []
const server = await start([behavior], {
chunkDelayMs: delayMs,
disconnectDelayMs: delayMs,
chunkSize: 1,
onEvent: (event) => { events.push(event) },
})
const controller = new AbortController()
const response = await chat(server, { signal: controller.signal })
controller.abort()
await expect(response.text()).rejects.toThrow()
await new Promise((resolve) => { setTimeout(resolve, 5) })
expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' })
expect(events.filter(event => event.type === 'result')).toEqual([
expect.objectContaining({ behavior, outcome: 'client_closed' }),
])
})
it('preserves UTF-8 code points split across request chunks', async () => {
const server = await start(['success'])
const encoded = Buffer.from(JSON.stringify({ messages: [{ role: 'user', content: '你好' }] }))
const characterOffset = encoded.indexOf(Buffer.from('你'))
expect(characterOffset).toBeGreaterThanOrEqual(0)
await rawChat(server, [
encoded.subarray(0, characterOffset + 1),
encoded.subarray(characterOffset + 1),
])
expect(server.requests[0]?.body).toEqual({ messages: [{ role: 'user', content: '你好' }] })
})
it('formats an IPv6 listener as a valid base URL', async () => {
const server = await start(['success'], { host: '::1' })
expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/)
expect((await chat(server)).status).toBe(200)
})
it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => {
const server = await start([
'reasoning_success',
'tool_call_success',
'max_tokens',
'slow_success',
'wrong_content_type',
], {
successText: 'answer',
reasoningText: 'think',
toolName: 'lookup',
toolArguments: '{"id":7}',
chunkDelayMs: 1,
chunkSize: 2,
})
const bodies: string[] = []
const contentTypes: Array<string | null> = []
for (let index = 0; index < 5; index += 1) {
const response = await chat(server)
contentTypes.push(response.headers.get('content-type'))
bodies.push(await response.text())
}
expect(bodies[0]).toContain('"reasoning_content":"th"')
expect(bodies[1]).toContain('"name":"lookup"')
expect(bodies[1]).toContain('"arguments":"{\\"id"')
expect(bodies[1]).toContain('"finish_reason":"tool_calls"')
expect(bodies[2]).toContain('"finish_reason":"length"')
expect(bodies[3]).toContain('"finish_reason":"stop"')
expect(contentTypes[4]).toBe('application/json')
expect(server.requests).toHaveLength(5)
expect(server.requests.every(record => record.outcome === 'completed')).toBe(true)
})
it.each([
['rate_limit', 429, 'mock rate limit'] as const,
['server_error', 500, 'mock server error'] as const,
['service_unavailable', 503, 'mock service unavailable'] as const,
['auth_error', 401, 'mock authentication failed'] as const,
['invalid_request', 400, 'mock invalid request'] as const,
['context_overflow', 400, 'context_length_exceeded'] as const,
['quota_exceeded', 429, 'insufficient_quota'] as const,
])('serves %s as a structured HTTP error', async (behavior, status, marker) => {
const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' })
const response = await chat(server)
const body = await response.text()
expect(response.status).toBe(status)
expect(body).toContain(marker)
expect(response.headers.get('x-request-id')).toBe('mock-request-1')
if (behavior === 'rate_limit') expect(response.headers.get('retry-after')).toBe('2')
else expect(response.headers.get('retry-after')).toBeNull()
expect(server.requests[0]?.outcome).toBe('completed')
})
it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => {
const exhausted = await start(['success'], { successText: 'once' })
await (await chat(exhausted)).text()
const exhaustedResponse = await chat(exhausted)
expect(exhaustedResponse.status).toBe(500)
expect(await exhaustedResponse.text()).toContain('mock script exhausted')
expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted'])
const repeating = await start(['empty'], { repeatLast: true })
await (await chat(repeating)).text()
await (await chat(repeating)).text()
expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty'])
})
it('selects weighted random behaviors reproducibly and reports the concrete choice', async () => {
const options = {
sequence: ['random'] as const,
repeatLast: true,
randomSeed: 42,
randomWeights: { success: 1, empty: 1 },
successText: 'random success',
}
const first = await startMockLlmServer(options)
const second = await startMockLlmServer(options)
running.push(first, second)
for (let attempt = 0; attempt < 12; attempt += 1) {
await (await chat(first)).text()
await (await chat(second)).text()
}
const firstChoices = first.requests.map(record => record.behavior)
expect(first.randomSeed).toBe(42)
expect(second.randomSeed).toBe(42)
expect(firstChoices).toEqual(second.requests.map(record => record.behavior))
expect(new Set(firstChoices)).toEqual(new Set(['success', 'empty']))
expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true)
})
it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => {
const server = await start(['success'], { apiKey: 'expected' })
const method = await fetch(`${server.baseURL}/v1/chat/completions`)
const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' })
const auth = await chat(server, { key: 'wrong' })
const json = await chat(server, { key: 'expected', body: '{' })
expect(method.status).toBe(405)
expect(method.headers.get('allow')).toBe('POST')
expect(route.status).toBe(404)
expect(auth.status).toBe(401)
expect(json.status).toBe(400)
expect(server.requests).toHaveLength(0)
const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, {
method: 'POST',
headers: { authorization: 'Bearer expected' },
})
expect(emptyRequest.status).toBe(200)
expect(server.requests[0]?.behavior).toBe('success')
expect(server.requests[0]?.body).toBeUndefined()
})
})
describe('mock LLM server option validation', () => {
it.each([
[{ sequence: [] }, /sequence/],
[{ sequence: ['success'], host: '' }, /host/],
[{ sequence: ['success'], port: -1 }, /port/],
[{ sequence: ['success'], port: 65_536 }, /port/],
[{ sequence: ['success'], apiKey: '' }, /apiKey/],
[{ sequence: ['success'], successText: '' }, /successText/],
[{ sequence: ['success'], partialText: '' }, /partialText/],
[{ sequence: ['success'], reasoningText: '' }, /reasoningText/],
[{ sequence: ['success'], chunkSize: 0 }, /chunkSize/],
[{ sequence: ['success'], chunkDelayMs: -1 }, /chunkDelayMs/],
[{ sequence: ['success'], disconnectDelayMs: Number.POSITIVE_INFINITY }, /disconnectDelayMs/],
[{ sequence: ['success'], retryAfterMs: 0 }, /retryAfterMs/],
[{ sequence: ['success'], requestId: '' }, /requestId/],
[{ sequence: ['success'], toolName: '' }, /toolName/],
[{ sequence: ['success'], toolArguments: '{' }, /toolArguments/],
[{ sequence: ['random'], randomSeed: -1 }, /randomSeed/],
[{ sequence: ['random'], randomWeights: { random: 1 } }, /unknown concrete behavior/],
[{ sequence: ['random'], randomWeights: { success: -1 } }, /non-negative/],
[{ sequence: ['random'], randomWeights: { success: 0 } }, /positive weight/],
] as const)('rejects invalid options %#', async (options, expected) => {
await expect(startMockLlmServer(options as Parameters<typeof startMockLlmServer>[0]))
.rejects.toThrow(expected)
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'tsdown'
/** Builds each public entry as a self-contained file admitted by the package whitelist. */
export default defineConfig([
{
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
{
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
},
])