Merge branch 'master' into fix-update-builderror
This commit is contained in:
@@ -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:^",
|
||||
|
||||
234
packages/llm/llm-retry/tests/transport-recovery.spec.ts
Normal file
234
packages/llm/llm-retry/tests/transport-recovery.spec.ts
Normal 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' } } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
84
packages/support/llm-mock-server/README.md
Normal file
84
packages/support/llm-mock-server/README.md
Normal 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.
|
||||
45
packages/support/llm-mock-server/package.json
Normal file
45
packages/support/llm-mock-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
50
packages/support/llm-mock-server/src/bin.ts
Normal file
50
packages/support/llm-mock-server/src/bin.ts
Normal 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 */
|
||||
222
packages/support/llm-mock-server/src/cli.ts
Normal file
222
packages/support/llm-mock-server/src/cli.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
738
packages/support/llm-mock-server/src/index.ts
Normal file
738
packages/support/llm-mock-server/src/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
30
packages/support/llm-mock-server/src/invariant.ts
Normal file
30
packages/support/llm-mock-server/src/invariant.ts
Normal 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 */
|
||||
124
packages/support/llm-mock-server/tests/cli.spec.ts
Normal file
124
packages/support/llm-mock-server/tests/cli.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
packages/support/llm-mock-server/tests/invariant.spec.ts
Normal file
18
packages/support/llm-mock-server/tests/invariant.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
355
packages/support/llm-mock-server/tests/server.spec.ts
Normal file
355
packages/support/llm-mock-server/tests/server.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
15
packages/support/llm-mock-server/tsconfig.json
Normal file
15
packages/support/llm-mock-server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
packages/support/llm-mock-server/tsdown.config.ts
Normal file
17
packages/support/llm-mock-server/tsdown.config.ts
Normal 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,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user