@deepseek-ai/dsh-llm-mock-server 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +86 -0
- package/README.zh.md +86 -0
- package/lib/index.js +520 -0
- package/lib/invariant.js +23 -0
- package/lib/types/bin.d.ts +7 -0
- package/lib/types/cli.d.ts +34 -0
- package/lib/types/index.d.ts +122 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/support/llm-mock-server/README.md
|
|
5
|
+
README.md: a535c086bf688ad48b1a3bb19c7b81da21cbad92
|
|
6
|
+
README.zh.md: d814d8815b38bb34bd0d871d552e6f3ec75e042a
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# `@deepseek-ai/dsh-llm-mock-server`
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## Standalone use
|
|
10
|
+
|
|
11
|
+
Run the source entry from this repository:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm run mock:llm -- \
|
|
15
|
+
--port 8000 \
|
|
16
|
+
--api-key mock-key \
|
|
17
|
+
--sequence partial_disconnect,success \
|
|
18
|
+
--partial-text "discard this half"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \
|
|
25
|
+
DEEPSEEK_API_KEY=mock-key \
|
|
26
|
+
pnpm run demo:headless "test provider recovery"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The repository script writes JSONL to stdout: 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. The private support package exposes no installable binary.
|
|
30
|
+
|
|
31
|
+
## Behavior script
|
|
32
|
+
|
|
33
|
+
`--sequence` is a comma-separated FIFO. Exhaustion returns a structured HTTP 500; `--repeat-last` explicitly reuses the last entry.
|
|
34
|
+
|
|
35
|
+
| Behavior | Wire result |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `connection_reset` | Destroy the socket before HTTP headers |
|
|
38
|
+
| `stream_disconnect` | Send SSE headers, then reset before the first event |
|
|
39
|
+
| `partial_disconnect` | Send text deltas, then reset the socket |
|
|
40
|
+
| `stall` | Send SSE headers and remain idle until client/server cancellation |
|
|
41
|
+
| `empty` | Send a valid content-less stop and `[DONE]` |
|
|
42
|
+
| `empty_body` / `stream_eof` / `partial_eof` | End cleanly without the required `[DONE]` boundary |
|
|
43
|
+
| `malformed_json` / `malformed_event` | Send invalid SSE JSON or an invalid provider chunk shape |
|
|
44
|
+
| `rate_limit` / `server_error` / `service_unavailable` | Return retry-oriented 429/500/503 JSON errors |
|
|
45
|
+
| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | Return terminal or separately recovered provider errors |
|
|
46
|
+
| `success` / `slow_success` / `reasoning_success` | Stream a complete text response, optionally delayed or preceded by reasoning |
|
|
47
|
+
| `tool_call_success` / `max_tokens` | Complete with a tool call or `length` finish |
|
|
48
|
+
| `wrong_content_type` | Send a valid SSE body under `application/json` |
|
|
49
|
+
| `random` | Select a concrete request behavior from weighted seeded randomness |
|
|
50
|
+
|
|
51
|
+
`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.
|
|
52
|
+
|
|
53
|
+
## Random mode
|
|
54
|
+
|
|
55
|
+
Use a repeating `random` entry for an open-ended mixed run:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pnpm run mock:llm -- \
|
|
59
|
+
--port 8000 \
|
|
60
|
+
--sequence random \
|
|
61
|
+
--repeat-last \
|
|
62
|
+
--seed 42 \
|
|
63
|
+
--random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
When random weights include `stall`, configure the client under test with a short stream-idle timeout so the scenario terminates promptly.
|
|
69
|
+
|
|
70
|
+
## Timing and content controls
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
## Model Experience
|
|
75
|
+
|
|
76
|
+
None, as this test server substitutes provider wire behavior without invoking a real model.
|
|
77
|
+
|
|
78
|
+
#### KV Cache effect
|
|
79
|
+
|
|
80
|
+
None; requests terminate locally and never reach a provider cache.
|
|
81
|
+
|
|
82
|
+
## Known Limitations and Deferred Work
|
|
83
|
+
|
|
84
|
+
- **Random weights model test pressure, not production incidence** — callers that want an environment-specific distribution must provide measured weights and record the emitted seed.
|
|
85
|
+
- **Request scripts are arrival-ordered** — concurrent callers share one cursor, so deterministic per-session fault assignment requires separate server instances.
|
|
86
|
+
- **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.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# `@deepseek-ai/dsh-llm-mock-server`
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
可编脚本的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)服务器,用于在无提供方密钥的情况下测试真实 LLM(大语言模型)适配器、agent loop(智能体循环)和恢复策略。它接受 `POST /chat/completions` 和 `POST /v1/chat/completions`;每个已接受请求按到达顺序消费一个已配置行为。无效的请求方法、路径、Bearer token 和 JSON 不会消费脚本条目。
|
|
6
|
+
|
|
7
|
+
库入口导出 `startMockLlmServer(options)`、行为类型和遥测(telemetry)类型、默认随机压力权重、Node 定时器允许的上限,以及带有绑定 `baseURL`、自动生成或显式配置 `randomSeed`、已捕获请求和幂等 `close()` 的运行句柄。关闭会强制终止停滞连接。
|
|
8
|
+
|
|
9
|
+
## 独立使用
|
|
10
|
+
|
|
11
|
+
从本仓库运行源入口:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm run mock:llm -- \
|
|
15
|
+
--port 8000 \
|
|
16
|
+
--api-key mock-key \
|
|
17
|
+
--sequence partial_disconnect,success \
|
|
18
|
+
--partial-text "discard this half"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
将发布的 DeepSeek 适配器指向服务器;它会将 `/chat/completions` 追加到已配置 base:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \
|
|
25
|
+
DEEPSEEK_API_KEY=mock-key \
|
|
26
|
+
pnpm run demo:headless "test provider recovery"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
仓库脚本将 JSONL 写入 stdout:`ready` 记录携带以 `/v1` 结尾的基础 URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包不公开可安装的二进制命令。
|
|
30
|
+
|
|
31
|
+
## 行为脚本
|
|
32
|
+
|
|
33
|
+
`--sequence` 是逗号分隔的 FIFO。耗尽时返回结构化 HTTP 500;`--repeat-last` 显式重用最后一项。
|
|
34
|
+
|
|
35
|
+
| 行为 | 协议结果 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `connection_reset` | 在发送 HTTP 标头前销毁 socket |
|
|
38
|
+
| `stream_disconnect` | 发送 SSE 标头,然后在第一个事件前重置连接 |
|
|
39
|
+
| `partial_disconnect` | 发送文本增量,然后重置 socket |
|
|
40
|
+
| `stall` | 发送 SSE header,并保持空闲,直到客户端/服务器取消 |
|
|
41
|
+
| `empty` | 发送有效的无内容 stop 和 `[DONE]` |
|
|
42
|
+
| `empty_body` / `stream_eof` / `partial_eof` | 正常结束,但缺少必需的 `[DONE]` 边界 |
|
|
43
|
+
| `malformed_json` / `malformed_event` | 发送无效 SSE JSON 或无效提供方分片形态 |
|
|
44
|
+
| `rate_limit` / `server_error` / `service_unavailable` | 返回面向重试的 429/500/503 JSON 错误 |
|
|
45
|
+
| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | 返回终止性错误或需要单独恢复的提供方错误 |
|
|
46
|
+
| `success` / `slow_success` / `reasoning_success` | 流式发送完整文本响应,可选延迟或先发送 reasoning |
|
|
47
|
+
| `tool_call_success` / `max_tokens` | 以工具调用或结束原因 `length` 完成 |
|
|
48
|
+
| `wrong_content_type` | 以 `application/json` 内容类型发送有效 SSE 正文 |
|
|
49
|
+
| `random` | 按带权重的种子随机选择具体请求行为 |
|
|
50
|
+
|
|
51
|
+
`connection_refused` 只能在 CLI 中使用,且必须是第一个条目。它会延迟绑定调用方指定的非零端口,因此 `--listen-delay-ms` 期间的请求会收到真实 TCP 拒绝;其余条目在 listener 启动后开始。
|
|
52
|
+
|
|
53
|
+
## 随机模式
|
|
54
|
+
|
|
55
|
+
使用重复 `random` 条目执行开放式混合运行:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pnpm run mock:llm -- \
|
|
59
|
+
--port 8000 \
|
|
60
|
+
--sequence random \
|
|
61
|
+
--repeat-last \
|
|
62
|
+
--seed 42 \
|
|
63
|
+
--random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
省略 `--seed` 会生成种子,并在 `ready` 记录中打印。`--random-weights` 接受非负的相对 `behavior=weight` 条目,并要求至少一个正权重具体行为。导出默认值是一个成功占主导的压力分布,包含 reset、disconnect、部分输出、空完成、stall、429/5xx、干净截断和格式错误的 JSON;它用于施加测试压力,而非估计生产事故频率。`connection_refused` 被排除,因为已绑定的请求处理器无法产生真实拒绝。
|
|
67
|
+
|
|
68
|
+
随机权重包含 `stall` 时,为待测客户端配置较短的流空闲超时,使场景及时结束。
|
|
69
|
+
|
|
70
|
+
## 时序与内容控制
|
|
71
|
+
|
|
72
|
+
CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 和 `--tool-arguments`。毫秒延迟是 Node timer 范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `Authorization: Bearer <token>`;省略时接受任何 token。
|
|
73
|
+
|
|
74
|
+
## 模型体验
|
|
75
|
+
|
|
76
|
+
无。该测试服务器替代提供方协议行为,而不调用真实模型。
|
|
77
|
+
|
|
78
|
+
#### KV Cache 影响
|
|
79
|
+
|
|
80
|
+
无;请求在本地终止,绝不会到达提供方缓存。
|
|
81
|
+
|
|
82
|
+
## 已知限制与暂缓事项
|
|
83
|
+
|
|
84
|
+
- **随机权重建模测试压力,而非生产事故频率**:需要环境专用分布的调用方必须提供已测量权重,并记录发出的种子。
|
|
85
|
+
- **请求脚本按到达顺序执行**:并发调用方共享一个游标,因此确定性的每会话故障分配需要独立服务器实例。
|
|
86
|
+
- **真实连接拒绝发生在监听器生命周期阶段**:CLI 延迟必须与客户端尝试重叠;请求级随机选择只能重置已接受的连接。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
//#region lib/types/index.js
|
|
6
|
+
/**
|
|
7
|
+
* Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and
|
|
8
|
+
* semantic-empty LLM recovery tests. Each accepted chat-completions request
|
|
9
|
+
* consumes one behavior; the server never retries or interprets harness policy.
|
|
10
|
+
*
|
|
11
|
+
* @module @deepseek-ai/dsh-llm-mock-server
|
|
12
|
+
*/
|
|
13
|
+
/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
|
|
14
|
+
const MOCK_LLM_BEHAVIORS = [
|
|
15
|
+
"connection_reset",
|
|
16
|
+
"stream_disconnect",
|
|
17
|
+
"empty",
|
|
18
|
+
"empty_body",
|
|
19
|
+
"stream_eof",
|
|
20
|
+
"partial_eof",
|
|
21
|
+
"partial_disconnect",
|
|
22
|
+
"stall",
|
|
23
|
+
"malformed_json",
|
|
24
|
+
"malformed_event",
|
|
25
|
+
"wrong_content_type",
|
|
26
|
+
"rate_limit",
|
|
27
|
+
"server_error",
|
|
28
|
+
"service_unavailable",
|
|
29
|
+
"auth_error",
|
|
30
|
+
"invalid_request",
|
|
31
|
+
"context_overflow",
|
|
32
|
+
"quota_exceeded",
|
|
33
|
+
"success",
|
|
34
|
+
"reasoning_success",
|
|
35
|
+
"tool_call_success",
|
|
36
|
+
"max_tokens",
|
|
37
|
+
"slow_success",
|
|
38
|
+
"random"
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Default stress profile for `random`. Weights are configurable test pressure,
|
|
42
|
+
* not a claim about production incident frequency.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS = Object.freeze({
|
|
45
|
+
success: 48,
|
|
46
|
+
slow_success: 10,
|
|
47
|
+
max_tokens: 2,
|
|
48
|
+
connection_reset: 5,
|
|
49
|
+
stream_disconnect: 5,
|
|
50
|
+
partial_disconnect: 10,
|
|
51
|
+
empty: 5,
|
|
52
|
+
stall: 2,
|
|
53
|
+
rate_limit: 5,
|
|
54
|
+
server_error: 4,
|
|
55
|
+
service_unavailable: 2,
|
|
56
|
+
partial_eof: 1,
|
|
57
|
+
malformed_json: 1
|
|
58
|
+
});
|
|
59
|
+
/** Largest millisecond delay accepted by Node timers without truncation. */
|
|
60
|
+
const MAX_MOCK_LLM_TIMER_DELAY_MS = 2147483647;
|
|
61
|
+
const DEFAULT_SUCCESS_TEXT = "mock response recovered";
|
|
62
|
+
const DEFAULT_PARTIAL_TEXT = "discarded partial response";
|
|
63
|
+
const DEFAULT_REASONING_TEXT = "mock reasoning";
|
|
64
|
+
const CONCRETE_BEHAVIORS = new Set(MOCK_LLM_BEHAVIORS.filter((behavior) => behavior !== "random"));
|
|
65
|
+
function boundedInteger(name, value, min, max) {
|
|
66
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`llm-mock-server: ${name} must be an integer between ${min} and ${max}`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function resolveOptions(options) {
|
|
70
|
+
const host = options.host ?? "127.0.0.1";
|
|
71
|
+
const port = boundedInteger("port", options.port ?? 0, 0, 65535);
|
|
72
|
+
const chunkSize = boundedInteger("chunkSize", options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER);
|
|
73
|
+
const chunkDelayMs = boundedInteger("chunkDelayMs", options.chunkDelayMs ?? 25, 0, MAX_MOCK_LLM_TIMER_DELAY_MS);
|
|
74
|
+
const disconnectDelayMs = boundedInteger("disconnectDelayMs", options.disconnectDelayMs ?? 10, 0, MAX_MOCK_LLM_TIMER_DELAY_MS);
|
|
75
|
+
const retryAfterMs = boundedInteger("retryAfterMs", options.retryAfterMs ?? 1e3, 1, MAX_MOCK_LLM_TIMER_DELAY_MS);
|
|
76
|
+
const randomSeed = boundedInteger("randomSeed", options.randomSeed ?? randomBytes(4).readUInt32LE(0), 0, 4294967295);
|
|
77
|
+
const successText = options.successText ?? DEFAULT_SUCCESS_TEXT;
|
|
78
|
+
const partialText = options.partialText ?? DEFAULT_PARTIAL_TEXT;
|
|
79
|
+
const reasoningText = options.reasoningText ?? DEFAULT_REASONING_TEXT;
|
|
80
|
+
const toolName = options.toolName ?? "mock_tool";
|
|
81
|
+
const toolArguments = options.toolArguments ?? "{\"value\":\"mock\"}";
|
|
82
|
+
if (host.length === 0) throw new Error("llm-mock-server: host must not be empty");
|
|
83
|
+
if (options.sequence.length === 0) throw new Error("llm-mock-server: sequence must not be empty");
|
|
84
|
+
const lastBehavior = options.sequence.reduce((_previous, behavior) => behavior);
|
|
85
|
+
if (options.apiKey === "") throw new Error("llm-mock-server: apiKey must not be empty");
|
|
86
|
+
if (successText.length === 0) throw new Error("llm-mock-server: successText must not be empty");
|
|
87
|
+
if (partialText.length === 0) throw new Error("llm-mock-server: partialText must not be empty");
|
|
88
|
+
if (reasoningText.length === 0) throw new Error("llm-mock-server: reasoningText must not be empty");
|
|
89
|
+
if (toolName.length === 0) throw new Error("llm-mock-server: toolName must not be empty");
|
|
90
|
+
if (options.requestId === "") throw new Error("llm-mock-server: requestId must not be empty");
|
|
91
|
+
try {
|
|
92
|
+
JSON.parse(toolArguments);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error("llm-mock-server: toolArguments must be valid JSON");
|
|
95
|
+
}
|
|
96
|
+
const configuredWeights = options.randomWeights ?? DEFAULT_MOCK_LLM_RANDOM_WEIGHTS;
|
|
97
|
+
const randomWeights = [];
|
|
98
|
+
for (const [behavior, weight] of Object.entries(configuredWeights)) {
|
|
99
|
+
if (!CONCRETE_BEHAVIORS.has(behavior)) throw new Error(`llm-mock-server: randomWeights contains unknown concrete behavior ${JSON.stringify(behavior)}`);
|
|
100
|
+
if (!Number.isFinite(weight) || weight < 0) throw new Error(`llm-mock-server: random weight for ${behavior} must be a non-negative finite number`);
|
|
101
|
+
if (weight > 0) randomWeights.push([behavior, weight]);
|
|
102
|
+
}
|
|
103
|
+
if (randomWeights.length === 0) throw new Error("llm-mock-server: randomWeights must contain at least one positive weight");
|
|
104
|
+
return {
|
|
105
|
+
host,
|
|
106
|
+
port,
|
|
107
|
+
...options.apiKey === void 0 ? {} : { apiKey: options.apiKey },
|
|
108
|
+
sequence: [...options.sequence],
|
|
109
|
+
lastBehavior,
|
|
110
|
+
repeatLast: options.repeatLast ?? false,
|
|
111
|
+
randomSeed,
|
|
112
|
+
randomWeights,
|
|
113
|
+
successText,
|
|
114
|
+
partialText,
|
|
115
|
+
reasoningText,
|
|
116
|
+
chunkSize,
|
|
117
|
+
chunkDelayMs,
|
|
118
|
+
disconnectDelayMs,
|
|
119
|
+
retryAfterMs,
|
|
120
|
+
...options.requestId === void 0 ? {} : { requestId: options.requestId },
|
|
121
|
+
toolName,
|
|
122
|
+
toolArguments,
|
|
123
|
+
...options.onEvent === void 0 ? {} : { onEvent: options.onEvent }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function emit(options, event) {
|
|
127
|
+
try {
|
|
128
|
+
options.onEvent?.(Object.freeze(event));
|
|
129
|
+
} catch (_telemetryObserverFailure) {}
|
|
130
|
+
}
|
|
131
|
+
async function readJsonBody(request) {
|
|
132
|
+
const chunks = [];
|
|
133
|
+
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
134
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
135
|
+
return body.length === 0 ? void 0 : JSON.parse(body);
|
|
136
|
+
}
|
|
137
|
+
function splitText(text, size) {
|
|
138
|
+
const points = Array.from(text);
|
|
139
|
+
const chunks = [];
|
|
140
|
+
for (let index = 0; index < points.length; index += size) chunks.push(points.slice(index, index + size).join(""));
|
|
141
|
+
return chunks;
|
|
142
|
+
}
|
|
143
|
+
function openSse(response, contentType = "text/event-stream; charset=utf-8") {
|
|
144
|
+
response.writeHead(200, {
|
|
145
|
+
"content-type": contentType,
|
|
146
|
+
"cache-control": "no-cache",
|
|
147
|
+
"connection": "keep-alive"
|
|
148
|
+
});
|
|
149
|
+
response.flushHeaders();
|
|
150
|
+
}
|
|
151
|
+
function writeSse(record, response, payload) {
|
|
152
|
+
response.write(`data: ${typeof payload === "string" ? payload : JSON.stringify(payload)}\n\n`);
|
|
153
|
+
record.chunksSent += 1;
|
|
154
|
+
}
|
|
155
|
+
function writeDone(record, response) {
|
|
156
|
+
writeSse(record, response, "[DONE]");
|
|
157
|
+
}
|
|
158
|
+
function finishRecord(options, record, outcome) {
|
|
159
|
+
if (record.outcome !== void 0) return;
|
|
160
|
+
record.outcome = outcome;
|
|
161
|
+
emit(options, {
|
|
162
|
+
type: "result",
|
|
163
|
+
attempt: record.attempt,
|
|
164
|
+
scriptBehavior: record.scriptBehavior,
|
|
165
|
+
behavior: record.behavior,
|
|
166
|
+
outcome,
|
|
167
|
+
chunksSent: record.chunksSent
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function httpError(options, record, response, status, message, code, type = "mock_error") {
|
|
171
|
+
const headers = { "content-type": "application/json" };
|
|
172
|
+
if (record.behavior === "rate_limit") headers["retry-after"] = String(Math.ceil(options.retryAfterMs / 1e3));
|
|
173
|
+
if (options.requestId !== void 0) headers["x-request-id"] = options.requestId;
|
|
174
|
+
response.writeHead(status, headers);
|
|
175
|
+
response.end(JSON.stringify({ error: {
|
|
176
|
+
message,
|
|
177
|
+
type,
|
|
178
|
+
code
|
|
179
|
+
} }));
|
|
180
|
+
finishRecord(options, record, "completed");
|
|
181
|
+
}
|
|
182
|
+
function terminalChunk(reason, outputTokens) {
|
|
183
|
+
return {
|
|
184
|
+
choices: [{
|
|
185
|
+
index: 0,
|
|
186
|
+
delta: { content: "" },
|
|
187
|
+
finish_reason: reason
|
|
188
|
+
}],
|
|
189
|
+
usage: {
|
|
190
|
+
prompt_tokens: 3,
|
|
191
|
+
completion_tokens: outputTokens
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async function pause(milliseconds, response) {
|
|
196
|
+
if (milliseconds === 0) return !response.destroyed;
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const stop = () => {
|
|
199
|
+
controller.abort();
|
|
200
|
+
};
|
|
201
|
+
response.once("close", stop);
|
|
202
|
+
try {
|
|
203
|
+
await setTimeout(milliseconds, void 0, { signal: controller.signal });
|
|
204
|
+
return true;
|
|
205
|
+
} catch (_responseClosed) {
|
|
206
|
+
return false;
|
|
207
|
+
} finally {
|
|
208
|
+
response.off("close", stop);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function streamText(options, record, response, text, delayMs) {
|
|
212
|
+
for (const chunk of splitText(text, options.chunkSize)) {
|
|
213
|
+
writeSse(record, response, { choices: [{
|
|
214
|
+
index: 0,
|
|
215
|
+
delta: { content: chunk },
|
|
216
|
+
finish_reason: null
|
|
217
|
+
}] });
|
|
218
|
+
if (!await pause(delayMs, response)) return false;
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
async function completeText(options, record, response, reason, delayMs) {
|
|
223
|
+
if (!await streamText(options, record, response, options.successText, delayMs)) {
|
|
224
|
+
finishRecord(options, record, "client_closed");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length));
|
|
228
|
+
writeDone(record, response);
|
|
229
|
+
response.end();
|
|
230
|
+
finishRecord(options, record, "completed");
|
|
231
|
+
}
|
|
232
|
+
async function disconnect(options, record, response) {
|
|
233
|
+
if (!await pause(options.disconnectDelayMs, response)) {
|
|
234
|
+
finishRecord(options, record, "client_closed");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
finishRecord(options, record, "reset");
|
|
238
|
+
response.destroy();
|
|
239
|
+
}
|
|
240
|
+
function toolCallChunks(options) {
|
|
241
|
+
const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2));
|
|
242
|
+
return [{ choices: [{
|
|
243
|
+
index: 0,
|
|
244
|
+
delta: { tool_calls: [{
|
|
245
|
+
index: 0,
|
|
246
|
+
id: "mock-call-1",
|
|
247
|
+
type: "function",
|
|
248
|
+
function: {
|
|
249
|
+
name: options.toolName,
|
|
250
|
+
arguments: options.toolArguments.slice(0, midpoint)
|
|
251
|
+
}
|
|
252
|
+
}] },
|
|
253
|
+
finish_reason: null
|
|
254
|
+
}] }, { choices: [{
|
|
255
|
+
index: 0,
|
|
256
|
+
delta: { tool_calls: [{
|
|
257
|
+
index: 0,
|
|
258
|
+
function: { arguments: options.toolArguments.slice(midpoint) }
|
|
259
|
+
}] },
|
|
260
|
+
finish_reason: null
|
|
261
|
+
}] }];
|
|
262
|
+
}
|
|
263
|
+
async function runBehavior(options, record, request, response) {
|
|
264
|
+
switch (record.behavior) {
|
|
265
|
+
case "script_exhausted":
|
|
266
|
+
httpError(options, record, response, 500, "mock script exhausted", "MOCK_SCRIPT_EXHAUSTED");
|
|
267
|
+
return;
|
|
268
|
+
case "connection_reset":
|
|
269
|
+
finishRecord(options, record, "reset");
|
|
270
|
+
request.socket.destroy();
|
|
271
|
+
return;
|
|
272
|
+
case "stream_disconnect":
|
|
273
|
+
openSse(response);
|
|
274
|
+
await disconnect(options, record, response);
|
|
275
|
+
return;
|
|
276
|
+
case "empty":
|
|
277
|
+
openSse(response);
|
|
278
|
+
writeSse(record, response, terminalChunk("stop", 0));
|
|
279
|
+
writeDone(record, response);
|
|
280
|
+
response.end();
|
|
281
|
+
finishRecord(options, record, "completed");
|
|
282
|
+
return;
|
|
283
|
+
case "empty_body":
|
|
284
|
+
openSse(response);
|
|
285
|
+
response.end();
|
|
286
|
+
finishRecord(options, record, "completed");
|
|
287
|
+
return;
|
|
288
|
+
case "stream_eof":
|
|
289
|
+
openSse(response);
|
|
290
|
+
writeSse(record, response, { choices: [{
|
|
291
|
+
index: 0,
|
|
292
|
+
delta: { role: "assistant" },
|
|
293
|
+
finish_reason: null
|
|
294
|
+
}] });
|
|
295
|
+
response.end();
|
|
296
|
+
finishRecord(options, record, "completed");
|
|
297
|
+
return;
|
|
298
|
+
case "partial_eof":
|
|
299
|
+
openSse(response);
|
|
300
|
+
await streamText(options, record, response, options.partialText, 0);
|
|
301
|
+
response.end();
|
|
302
|
+
finishRecord(options, record, "completed");
|
|
303
|
+
return;
|
|
304
|
+
case "partial_disconnect":
|
|
305
|
+
openSse(response);
|
|
306
|
+
if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return;
|
|
307
|
+
await disconnect(options, record, response);
|
|
308
|
+
return;
|
|
309
|
+
case "stall":
|
|
310
|
+
openSse(response);
|
|
311
|
+
finishRecord(options, record, "stalled");
|
|
312
|
+
return;
|
|
313
|
+
case "malformed_json":
|
|
314
|
+
openSse(response);
|
|
315
|
+
writeSse(record, response, "{not-json");
|
|
316
|
+
writeDone(record, response);
|
|
317
|
+
response.end();
|
|
318
|
+
finishRecord(options, record, "completed");
|
|
319
|
+
return;
|
|
320
|
+
case "malformed_event":
|
|
321
|
+
openSse(response);
|
|
322
|
+
writeSse(record, response, { choices: [null] });
|
|
323
|
+
writeDone(record, response);
|
|
324
|
+
response.end();
|
|
325
|
+
finishRecord(options, record, "completed");
|
|
326
|
+
return;
|
|
327
|
+
case "wrong_content_type":
|
|
328
|
+
openSse(response, "application/json");
|
|
329
|
+
await completeText(options, record, response, "stop", 0);
|
|
330
|
+
return;
|
|
331
|
+
case "rate_limit":
|
|
332
|
+
httpError(options, record, response, 429, "mock rate limit", "rate_limit");
|
|
333
|
+
return;
|
|
334
|
+
case "server_error":
|
|
335
|
+
httpError(options, record, response, 500, "mock server error", "server_error");
|
|
336
|
+
return;
|
|
337
|
+
case "service_unavailable":
|
|
338
|
+
httpError(options, record, response, 503, "mock service unavailable", "service_unavailable");
|
|
339
|
+
return;
|
|
340
|
+
case "auth_error":
|
|
341
|
+
httpError(options, record, response, 401, "mock authentication failed", "invalid_api_key");
|
|
342
|
+
return;
|
|
343
|
+
case "invalid_request":
|
|
344
|
+
httpError(options, record, response, 400, "mock invalid request", "invalid_request");
|
|
345
|
+
return;
|
|
346
|
+
case "context_overflow":
|
|
347
|
+
httpError(options, record, response, 400, "mock input exceeds the model context window", "context_length_exceeded", "invalid_request_error");
|
|
348
|
+
return;
|
|
349
|
+
case "quota_exceeded":
|
|
350
|
+
httpError(options, record, response, 429, "mock insufficient quota", "insufficient_quota");
|
|
351
|
+
return;
|
|
352
|
+
case "success":
|
|
353
|
+
openSse(response);
|
|
354
|
+
await completeText(options, record, response, "stop", 0);
|
|
355
|
+
return;
|
|
356
|
+
case "reasoning_success":
|
|
357
|
+
openSse(response);
|
|
358
|
+
for (const chunk of splitText(options.reasoningText, options.chunkSize)) writeSse(record, response, { choices: [{
|
|
359
|
+
index: 0,
|
|
360
|
+
delta: { reasoning_content: chunk },
|
|
361
|
+
finish_reason: null
|
|
362
|
+
}] });
|
|
363
|
+
await completeText(options, record, response, "stop", 0);
|
|
364
|
+
return;
|
|
365
|
+
case "tool_call_success":
|
|
366
|
+
openSse(response);
|
|
367
|
+
for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk);
|
|
368
|
+
writeSse(record, response, terminalChunk("tool_calls", 2));
|
|
369
|
+
writeDone(record, response);
|
|
370
|
+
response.end();
|
|
371
|
+
finishRecord(options, record, "completed");
|
|
372
|
+
return;
|
|
373
|
+
case "max_tokens":
|
|
374
|
+
openSse(response);
|
|
375
|
+
await completeText(options, record, response, "length", 0);
|
|
376
|
+
return;
|
|
377
|
+
case "slow_success":
|
|
378
|
+
openSse(response);
|
|
379
|
+
await completeText(options, record, response, "stop", options.chunkDelayMs);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function seededRandom(seed) {
|
|
384
|
+
let state = seed;
|
|
385
|
+
return () => {
|
|
386
|
+
state = state + 1831565813 >>> 0;
|
|
387
|
+
let mixed = state;
|
|
388
|
+
mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1);
|
|
389
|
+
mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
|
|
390
|
+
return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function chooseRandomBehavior(weights, random) {
|
|
394
|
+
const total = weights.reduce((sum, entry) => sum + entry[1], 0);
|
|
395
|
+
let draw = random() * total;
|
|
396
|
+
for (const [behavior, weight] of weights) {
|
|
397
|
+
if (draw < weight) return behavior;
|
|
398
|
+
draw -= weight;
|
|
399
|
+
}
|
|
400
|
+
/* v8 ignore next -- seededRandom is strictly less than one; this guards floating-point residue only */
|
|
401
|
+
return weights.at(-1)[0];
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Start a local chat-completions server that consumes one configured behavior
|
|
405
|
+
* per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script;
|
|
406
|
+
* invalid routes, methods, authorization, and JSON receive ordinary 4xx
|
|
407
|
+
* responses. Closing the handle terminates stalled connections.
|
|
408
|
+
*
|
|
409
|
+
* @param options - listener, script, response content, timing, and telemetry options.
|
|
410
|
+
* @returns the listening handle after the port is bound.
|
|
411
|
+
*/
|
|
412
|
+
async function startMockLlmServer(options) {
|
|
413
|
+
const resolved = resolveOptions(options);
|
|
414
|
+
const requests = [];
|
|
415
|
+
const random = seededRandom(resolved.randomSeed);
|
|
416
|
+
let cursor = 0;
|
|
417
|
+
const selectBehavior = () => {
|
|
418
|
+
const selected = resolved.sequence[cursor];
|
|
419
|
+
cursor += 1;
|
|
420
|
+
const scriptBehavior = selected ?? (resolved.repeatLast ? resolved.lastBehavior : "script_exhausted");
|
|
421
|
+
return {
|
|
422
|
+
scriptBehavior,
|
|
423
|
+
behavior: scriptBehavior === "random" ? chooseRandomBehavior(resolved.randomWeights, random) : scriptBehavior
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
const handle = async (request, response) => {
|
|
427
|
+
/* v8 ignore next -- node:http server requests always carry a URL despite the shared optional type */
|
|
428
|
+
const path = new URL(request.url ?? "/", "http://mock.invalid").pathname;
|
|
429
|
+
if (request.method !== "POST") {
|
|
430
|
+
response.writeHead(405, { allow: "POST" }).end();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (!path.endsWith("/chat/completions")) {
|
|
434
|
+
response.writeHead(404).end();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (resolved.apiKey !== void 0 && request.headers.authorization !== `Bearer ${resolved.apiKey}`) {
|
|
438
|
+
response.writeHead(401, { "content-type": "application/json" });
|
|
439
|
+
response.end(JSON.stringify({ error: {
|
|
440
|
+
message: "invalid mock bearer token",
|
|
441
|
+
code: "invalid_api_key"
|
|
442
|
+
} }));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
let body;
|
|
446
|
+
try {
|
|
447
|
+
body = await readJsonBody(request);
|
|
448
|
+
} catch {
|
|
449
|
+
response.writeHead(400, { "content-type": "application/json" });
|
|
450
|
+
response.end(JSON.stringify({ error: {
|
|
451
|
+
message: "request body must be valid JSON",
|
|
452
|
+
code: "invalid_json"
|
|
453
|
+
} }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const selected = selectBehavior();
|
|
457
|
+
const record = {
|
|
458
|
+
attempt: requests.length + 1,
|
|
459
|
+
scriptBehavior: selected.scriptBehavior,
|
|
460
|
+
behavior: selected.behavior,
|
|
461
|
+
path,
|
|
462
|
+
headers: { ...request.headers },
|
|
463
|
+
body,
|
|
464
|
+
chunksSent: 0
|
|
465
|
+
};
|
|
466
|
+
requests.push(record);
|
|
467
|
+
response.once("close", () => {
|
|
468
|
+
if (!response.writableFinished && record.outcome === void 0) finishRecord(resolved, record, "client_closed");
|
|
469
|
+
});
|
|
470
|
+
emit(resolved, {
|
|
471
|
+
type: "request",
|
|
472
|
+
attempt: record.attempt,
|
|
473
|
+
scriptBehavior: record.scriptBehavior,
|
|
474
|
+
behavior: record.behavior,
|
|
475
|
+
path
|
|
476
|
+
});
|
|
477
|
+
await runBehavior(resolved, record, request, response);
|
|
478
|
+
};
|
|
479
|
+
const server = createServer((request, response) => {
|
|
480
|
+
/* v8 ignore start -- last-resort containment for Node response failures after validated test inputs */
|
|
481
|
+
handle(request, response).catch((error) => {
|
|
482
|
+
const record = requests.at(-1);
|
|
483
|
+
if (record !== void 0) finishRecord(resolved, record, "server_error");
|
|
484
|
+
if (response.headersSent) {
|
|
485
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
response.writeHead(500, { "content-type": "application/json" });
|
|
489
|
+
response.end(JSON.stringify({ error: {
|
|
490
|
+
message: "mock server handler failed",
|
|
491
|
+
code: "MOCK_HANDLER_FAILED"
|
|
492
|
+
} }));
|
|
493
|
+
});
|
|
494
|
+
/* v8 ignore stop */
|
|
495
|
+
});
|
|
496
|
+
let closing;
|
|
497
|
+
const close = () => closing ??= new Promise((resolveClose) => {
|
|
498
|
+
server.close(() => {
|
|
499
|
+
resolveClose();
|
|
500
|
+
});
|
|
501
|
+
server.closeAllConnections();
|
|
502
|
+
});
|
|
503
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
504
|
+
server.once("error", rejectListen);
|
|
505
|
+
server.listen(resolved.port, resolved.host, () => {
|
|
506
|
+
server.off("error", rejectListen);
|
|
507
|
+
resolveListen();
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
const address = server.address();
|
|
511
|
+
return {
|
|
512
|
+
baseURL: `http://${isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host}:${address.port}`,
|
|
513
|
+
port: address.port,
|
|
514
|
+
randomSeed: resolved.randomSeed,
|
|
515
|
+
requests,
|
|
516
|
+
close
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
export { DEFAULT_MOCK_LLM_RANDOM_WEIGHTS, MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS, startMockLlmServer };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`.
|
|
4
|
+
* @module @deepseek-ai/dsh-llm-mock-server/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-llm-mock-server";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "llm-mock-server-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this standalone test server owns no Cordis event stream or shared data;
|
|
13
|
+
* its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free CLI parsing for the standalone mock LLM server.
|
|
3
|
+
* @module @deepseek-ai/dsh-llm-mock-server/cli
|
|
4
|
+
*/
|
|
5
|
+
import type { MockLlmServerOptions } from './index.ts';
|
|
6
|
+
/** Listener lifecycle behavior understood only by the standalone CLI. */
|
|
7
|
+
export declare const CONNECTION_REFUSED_BEHAVIOR = "connection_refused";
|
|
8
|
+
/** Parsed CLI configuration, including a pre-listen unavailable interval. */
|
|
9
|
+
export interface MockLlmCliConfig {
|
|
10
|
+
/** Server options after removing the lifecycle-only `connection_refused` entry. */
|
|
11
|
+
readonly server: MockLlmServerOptions;
|
|
12
|
+
/** Delay before binding the model port; an integer from zero through the Node timer maximum. */
|
|
13
|
+
readonly listenDelayMs: number;
|
|
14
|
+
/** Whether the original sequence requested a true pre-listen refusal phase. */
|
|
15
|
+
readonly startsUnavailable: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Result of parsing `dsh-llm-mock-server` arguments. */
|
|
18
|
+
export type MockLlmCliParseResult = {
|
|
19
|
+
readonly kind: 'help';
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'run';
|
|
22
|
+
readonly config: MockLlmCliConfig;
|
|
23
|
+
};
|
|
24
|
+
/** Command usage written for `--help` and invalid arguments. */
|
|
25
|
+
export declare const MOCK_LLM_CLI_USAGE = "Usage: dsh-llm-mock-server [options]\n\nRequired:\n --sequence <a,b,...> Ordered behaviors; connection_refused is allowed first\n\nListener:\n --host <host> Default 127.0.0.1\n --port <port> Default 8000; required and nonzero for connection_refused\n --api-key <token> Validate exact Bearer token when present\n --listen-delay-ms <ms> Unavailable interval (default 750 with connection_refused)\n --repeat-last Repeat the final request behavior after exhaustion\n --seed <uint32> Reproduce random selections\n --random-weights <a=n,...> Relative weights for concrete behaviors\n\nResponse:\n --success-text <text>\n --partial-text <text>\n --reasoning-text <text>\n --chunk-size <count>\n --chunk-delay-ms <ms>\n --disconnect-delay-ms <ms>\n --retry-after-ms <ms>\n --request-id <id>\n --tool-name <name>\n --tool-arguments <json>\n\nOther:\n --help\n";
|
|
26
|
+
/**
|
|
27
|
+
* Parse standalone server arguments without starting a process or listener.
|
|
28
|
+
* Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric
|
|
29
|
+
* coercion, bounds, and cross-option constraints remain manual below it.
|
|
30
|
+
* @param argv - arguments after the executable name.
|
|
31
|
+
* @returns help or validated run configuration.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult;
|
|
34
|
+
//# sourceMappingURL=cli.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and
|
|
3
|
+
* semantic-empty LLM recovery tests. Each accepted chat-completions request
|
|
4
|
+
* consumes one behavior; the server never retries or interprets harness policy.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-llm-mock-server
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
9
|
+
/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */
|
|
10
|
+
export declare const MOCK_LLM_BEHAVIORS: readonly ["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"];
|
|
11
|
+
/** One scripted mock behavior name; `random` selects a concrete behavior per request. */
|
|
12
|
+
export type MockLlmBehavior = typeof MOCK_LLM_BEHAVIORS[number];
|
|
13
|
+
/** One concrete request behavior after resolving a `random` script entry. */
|
|
14
|
+
export type ConcreteMockLlmBehavior = Exclude<MockLlmBehavior, 'random'>;
|
|
15
|
+
/** Relative non-negative weights for random request behavior selection. */
|
|
16
|
+
export type MockLlmRandomWeights = Partial<Record<ConcreteMockLlmBehavior, number>>;
|
|
17
|
+
/**
|
|
18
|
+
* Default stress profile for `random`. Weights are configurable test pressure,
|
|
19
|
+
* not a claim about production incident frequency.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights>;
|
|
22
|
+
/** Largest millisecond delay accepted by Node timers without truncation. */
|
|
23
|
+
export declare const MAX_MOCK_LLM_TIMER_DELAY_MS = 2147483647;
|
|
24
|
+
/** How one accepted request ended at the mock boundary. */
|
|
25
|
+
export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error';
|
|
26
|
+
/** Immutable telemetry emitted when a request starts or reaches an outcome. */
|
|
27
|
+
export type MockLlmServerEvent = {
|
|
28
|
+
readonly type: 'request';
|
|
29
|
+
readonly attempt: number;
|
|
30
|
+
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted';
|
|
31
|
+
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted';
|
|
32
|
+
readonly path: string;
|
|
33
|
+
} | {
|
|
34
|
+
readonly type: 'result';
|
|
35
|
+
readonly attempt: number;
|
|
36
|
+
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted';
|
|
37
|
+
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted';
|
|
38
|
+
readonly outcome: MockLlmRequestOutcome;
|
|
39
|
+
readonly chunksSent: number;
|
|
40
|
+
};
|
|
41
|
+
/** Captured wire request and its final server-side outcome. */
|
|
42
|
+
export interface MockLlmRequestRecord {
|
|
43
|
+
/** One-based accepted chat-completions request number. */
|
|
44
|
+
readonly attempt: number;
|
|
45
|
+
/** Script entry consumed for this request before random resolution. */
|
|
46
|
+
readonly scriptBehavior: MockLlmBehavior | 'script_exhausted';
|
|
47
|
+
/** Concrete behavior selected for this request, or exhaustion after the configured script. */
|
|
48
|
+
readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted';
|
|
49
|
+
/** Original request path, including a `/v1` prefix when the client supplied one. */
|
|
50
|
+
readonly path: string;
|
|
51
|
+
/** Detached request headers. */
|
|
52
|
+
readonly headers: Readonly<IncomingHttpHeaders>;
|
|
53
|
+
/** Parsed JSON request body. */
|
|
54
|
+
readonly body: unknown;
|
|
55
|
+
/** Number of SSE `data:` events handed to Node before the outcome. */
|
|
56
|
+
chunksSent: number;
|
|
57
|
+
/** Final server-side outcome; absent while a stalled request remains open. */
|
|
58
|
+
outcome?: MockLlmRequestOutcome;
|
|
59
|
+
}
|
|
60
|
+
/** Configuration for one mock server instance. */
|
|
61
|
+
export interface MockLlmServerOptions {
|
|
62
|
+
/** Loopback host by default. */
|
|
63
|
+
readonly host?: string;
|
|
64
|
+
/** TCP port; zero requests an OS-assigned port. */
|
|
65
|
+
readonly port?: number;
|
|
66
|
+
/** Optional exact bearer token; omission accepts any authorization header. */
|
|
67
|
+
readonly apiKey?: string;
|
|
68
|
+
/** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */
|
|
69
|
+
readonly sequence: readonly MockLlmBehavior[];
|
|
70
|
+
/** Reuse the final behavior after the sequence is consumed. */
|
|
71
|
+
readonly repeatLast?: boolean;
|
|
72
|
+
/** Optional deterministic unsigned 32-bit seed; omission generates and exposes one. */
|
|
73
|
+
readonly randomSeed?: number;
|
|
74
|
+
/** Relative weights used whenever a script entry is `random`. */
|
|
75
|
+
readonly randomWeights?: Readonly<MockLlmRandomWeights>;
|
|
76
|
+
/** Complete text returned by success-shaped behaviors. */
|
|
77
|
+
readonly successText?: string;
|
|
78
|
+
/** Text emitted before partial EOF/reset behaviors terminate. */
|
|
79
|
+
readonly partialText?: string;
|
|
80
|
+
/** Reasoning text emitted by `reasoning_success`. */
|
|
81
|
+
readonly reasoningText?: string;
|
|
82
|
+
/** Unicode code-point count per text or reasoning SSE delta. */
|
|
83
|
+
readonly chunkSize?: number;
|
|
84
|
+
/** Inter-chunk delay for `slow_success`, in milliseconds. */
|
|
85
|
+
readonly chunkDelayMs?: number;
|
|
86
|
+
/** Delay after headers/deltas before a forced disconnect, in milliseconds. */
|
|
87
|
+
readonly disconnectDelayMs?: number;
|
|
88
|
+
/** Provider retry delay; the wire `Retry-After` value rounds up to whole seconds. */
|
|
89
|
+
readonly retryAfterMs?: number;
|
|
90
|
+
/** Optional provider request id returned on HTTP failures. */
|
|
91
|
+
readonly requestId?: string;
|
|
92
|
+
/** Tool name emitted by `tool_call_success`. */
|
|
93
|
+
readonly toolName?: string;
|
|
94
|
+
/** Raw JSON arguments emitted by `tool_call_success`. */
|
|
95
|
+
readonly toolArguments?: string;
|
|
96
|
+
/** Optional observer for JSONL CLI telemetry; observer failures never affect wire behavior. */
|
|
97
|
+
readonly onEvent?: (event: MockLlmServerEvent) => void;
|
|
98
|
+
}
|
|
99
|
+
/** Running mock server and captured request state. */
|
|
100
|
+
export interface MockLlmServer {
|
|
101
|
+
/** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */
|
|
102
|
+
readonly baseURL: string;
|
|
103
|
+
/** Actual bound port, including an OS-assigned value. */
|
|
104
|
+
readonly port: number;
|
|
105
|
+
/** Seed used for random behavior selection, including the generated default. */
|
|
106
|
+
readonly randomSeed: number;
|
|
107
|
+
/** Live request records in arrival order. */
|
|
108
|
+
readonly requests: readonly MockLlmRequestRecord[];
|
|
109
|
+
/** Stop accepting requests and force-close stalled/streaming connections; idempotent. */
|
|
110
|
+
close(): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Start a local chat-completions server that consumes one configured behavior
|
|
114
|
+
* per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script;
|
|
115
|
+
* invalid routes, methods, authorization, and JSON receive ordinary 4xx
|
|
116
|
+
* responses. Closing the handle terminates stalled connections.
|
|
117
|
+
*
|
|
118
|
+
* @param options - listener, script, response content, timing, and telemetry options.
|
|
119
|
+
* @returns the listening handle after the port is bound.
|
|
120
|
+
*/
|
|
121
|
+
export declare function startMockLlmServer(options: MockLlmServerOptions): Promise<MockLlmServer>;
|
|
122
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`.
|
|
3
|
+
* @module @deepseek-ai/dsh-llm-mock-server/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "llm-mock-server-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-llm-mock-server",
|
|
3
|
+
"description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/support/llm-mock-server"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1"
|
|
41
|
+
}
|
|
42
|
+
}
|