llm-mock-server 1.0.2 → 1.0.3
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/.editorconfig +12 -0
- package/.github/workflows/test.yml +3 -0
- package/.oxfmtrc.json +9 -0
- package/package.json +5 -2
- package/src/cli-validators.ts +12 -4
- package/src/cli.ts +22 -6
- package/src/formats/anthropic/parse.ts +24 -5
- package/src/formats/anthropic/schema.ts +16 -8
- package/src/formats/anthropic/serialize.ts +111 -27
- package/src/formats/openai/parse.ts +12 -2
- package/src/formats/openai/schema.ts +43 -30
- package/src/formats/openai/serialize.ts +73 -17
- package/src/formats/request-helpers.ts +2 -1
- package/src/formats/responses/parse.ts +17 -3
- package/src/formats/responses/schema.ts +34 -20
- package/src/formats/responses/serialize.ts +233 -38
- package/src/formats/serialize-helpers.ts +10 -2
- package/src/formats/types.ts +16 -3
- package/src/index.ts +3 -1
- package/src/loader.ts +36 -9
- package/src/logger.ts +25 -7
- package/src/mock-server.ts +28 -7
- package/src/route-handler.ts +49 -14
- package/src/rule-engine.ts +43 -12
- package/src/types/reply.ts +6 -2
- package/src/types.ts +24 -3
- package/test/cli-validators.test.ts +16 -4
- package/test/formats/anthropic.test.ts +80 -19
- package/test/formats/openai.test.ts +85 -24
- package/test/formats/parse-helpers.test.ts +47 -7
- package/test/formats/responses.test.ts +95 -30
- package/test/history.test.ts +18 -5
- package/test/loader.test.ts +33 -18
- package/test/logger.test.ts +59 -9
- package/test/mock-server.test.ts +76 -22
- package/test/rule-engine.test.ts +49 -19
package/src/index.ts
CHANGED
|
@@ -37,7 +37,9 @@ import type { MockServerOptions } from "./mock-server.js";
|
|
|
37
37
|
* await server.stop();
|
|
38
38
|
* ```
|
|
39
39
|
*/
|
|
40
|
-
export async function createMock(
|
|
40
|
+
export async function createMock(
|
|
41
|
+
options: MockServerOptions = {},
|
|
42
|
+
): Promise<MockServer> {
|
|
41
43
|
const server = new MockServer(options);
|
|
42
44
|
await server.start(options.port ?? 0);
|
|
43
45
|
return server;
|
package/src/loader.ts
CHANGED
|
@@ -25,7 +25,11 @@ const json5ReplySchema = z.union([
|
|
|
25
25
|
z.object({
|
|
26
26
|
text: z.string().optional(),
|
|
27
27
|
reasoning: z.string().optional(),
|
|
28
|
-
tools: z
|
|
28
|
+
tools: z
|
|
29
|
+
.array(
|
|
30
|
+
z.object({ name: z.string(), args: z.record(z.string(), z.unknown()) }),
|
|
31
|
+
)
|
|
32
|
+
.optional(),
|
|
29
33
|
}),
|
|
30
34
|
]);
|
|
31
35
|
|
|
@@ -77,7 +81,9 @@ function compileMatch(when: z.infer<typeof json5MatchSchema>): Match {
|
|
|
77
81
|
return parseRegexString(when);
|
|
78
82
|
}
|
|
79
83
|
const obj: MatchObject = {
|
|
80
|
-
...(when.message !== undefined && {
|
|
84
|
+
...(when.message !== undefined && {
|
|
85
|
+
message: parseRegexString(when.message),
|
|
86
|
+
}),
|
|
81
87
|
...(when.model !== undefined && { model: parseRegexString(when.model) }),
|
|
82
88
|
...(when.system !== undefined && { system: parseRegexString(when.system) }),
|
|
83
89
|
...(when.format !== undefined && { format: when.format }),
|
|
@@ -124,14 +130,21 @@ function addSequenceRule(
|
|
|
124
130
|
rule.remaining = entryCount;
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
async function loadJson5File(
|
|
133
|
+
async function loadJson5File(
|
|
134
|
+
filePath: string,
|
|
135
|
+
ctx: LoadContext,
|
|
136
|
+
): Promise<void> {
|
|
128
137
|
const content = await readFile(filePath, "utf-8");
|
|
129
138
|
const parsed = json5FileSchema.parse(JSON5.parse(content));
|
|
130
139
|
|
|
131
140
|
const rules = Array.isArray(parsed) ? parsed : parsed.rules;
|
|
132
141
|
const templates = Array.isArray(parsed) ? undefined : parsed.templates;
|
|
133
142
|
|
|
134
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
!Array.isArray(parsed) &&
|
|
145
|
+
parsed.fallback !== undefined &&
|
|
146
|
+
ctx.setFallback
|
|
147
|
+
) {
|
|
135
148
|
ctx.setFallback(parsed.fallback);
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -152,7 +165,9 @@ async function loadJson5File(filePath: string, ctx: LoadContext): Promise<void>
|
|
|
152
165
|
const handlerSchema = z.custom<Handler>((val): val is Handler => {
|
|
153
166
|
if (typeof val !== "object" || val === null) return false;
|
|
154
167
|
const obj = val as Record<string, unknown>;
|
|
155
|
-
return
|
|
168
|
+
return (
|
|
169
|
+
typeof obj["match"] === "function" && typeof obj["respond"] === "function"
|
|
170
|
+
);
|
|
156
171
|
});
|
|
157
172
|
|
|
158
173
|
const handlerExportSchema = z.object({
|
|
@@ -160,7 +175,10 @@ const handlerExportSchema = z.object({
|
|
|
160
175
|
fallback: json5ReplySchema.optional(),
|
|
161
176
|
});
|
|
162
177
|
|
|
163
|
-
async function loadHandlerFile(
|
|
178
|
+
async function loadHandlerFile(
|
|
179
|
+
filePath: string,
|
|
180
|
+
ctx: LoadContext,
|
|
181
|
+
): Promise<void> {
|
|
164
182
|
const mod = await import(filePath);
|
|
165
183
|
const parsed = handlerExportSchema.safeParse(mod);
|
|
166
184
|
if (!parsed.success) {
|
|
@@ -168,14 +186,20 @@ async function loadHandlerFile(filePath: string, ctx: LoadContext): Promise<void
|
|
|
168
186
|
`Invalid handler file ${filePath}. Expected default export with { match: Function, respond: Function }.`,
|
|
169
187
|
);
|
|
170
188
|
}
|
|
171
|
-
const handlers = Array.isArray(parsed.data.default)
|
|
189
|
+
const handlers = Array.isArray(parsed.data.default)
|
|
190
|
+
? parsed.data.default
|
|
191
|
+
: [parsed.data.default];
|
|
172
192
|
|
|
173
193
|
if (parsed.data.fallback !== undefined && ctx.setFallback) {
|
|
174
194
|
ctx.setFallback(parsed.data.fallback);
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
for (const handler of handlers) {
|
|
178
|
-
ctx.engine.addHandler(
|
|
198
|
+
ctx.engine.addHandler(
|
|
199
|
+
handler.match,
|
|
200
|
+
handler.respond,
|
|
201
|
+
`(handler: ${filePath})`,
|
|
202
|
+
);
|
|
179
203
|
}
|
|
180
204
|
}
|
|
181
205
|
|
|
@@ -189,7 +213,10 @@ const loaderByExtension: ReadonlyMap<string, FileLoader> = new Map([
|
|
|
189
213
|
[".mjs", loadHandlerFile],
|
|
190
214
|
]);
|
|
191
215
|
|
|
192
|
-
export async function loadRulesFromPath(
|
|
216
|
+
export async function loadRulesFromPath(
|
|
217
|
+
pathOrDir: string,
|
|
218
|
+
ctx: LoadContext,
|
|
219
|
+
): Promise<void> {
|
|
193
220
|
const info = await stat(pathOrDir);
|
|
194
221
|
|
|
195
222
|
if (info.isFile()) {
|
package/src/logger.ts
CHANGED
|
@@ -21,7 +21,10 @@ const LEVEL_STYLE = {
|
|
|
21
21
|
|
|
22
22
|
type ConsoleMethod = "error" | "warn" | "log";
|
|
23
23
|
|
|
24
|
-
const LEVEL_CONFIG: Record<
|
|
24
|
+
const LEVEL_CONFIG: Record<
|
|
25
|
+
keyof typeof LEVEL_STYLE,
|
|
26
|
+
{ priority: number; method: ConsoleMethod; dim?: boolean }
|
|
27
|
+
> = {
|
|
25
28
|
error: { priority: LEVEL_PRIORITY.error, method: "error" },
|
|
26
29
|
warn: { priority: LEVEL_PRIORITY.warning, method: "warn" },
|
|
27
30
|
info: { priority: LEVEL_PRIORITY.info, method: "log" },
|
|
@@ -37,16 +40,31 @@ export class Logger {
|
|
|
37
40
|
this.threshold = LEVEL_PRIORITY[level];
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
private log(
|
|
43
|
+
private log(
|
|
44
|
+
key: keyof typeof LEVEL_STYLE,
|
|
45
|
+
msg: string,
|
|
46
|
+
args: unknown[],
|
|
47
|
+
): void {
|
|
41
48
|
const config = LEVEL_CONFIG[key];
|
|
42
49
|
if (this.threshold < config.priority) return;
|
|
43
50
|
const { label, symbol } = LEVEL_STYLE[key];
|
|
44
51
|
const text = config.dim ? pc.dim(msg) : msg;
|
|
45
|
-
console[config.method](
|
|
52
|
+
console[config.method](
|
|
53
|
+
`${pc.dim(new Date().toISOString())} ${symbol} ${label} ${text}`,
|
|
54
|
+
...args,
|
|
55
|
+
);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
error(msg: string, ...args: unknown[]): void {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
error(msg: string, ...args: unknown[]): void {
|
|
59
|
+
this.log("error", msg, args);
|
|
60
|
+
}
|
|
61
|
+
warn(msg: string, ...args: unknown[]): void {
|
|
62
|
+
this.log("warn", msg, args);
|
|
63
|
+
}
|
|
64
|
+
info(msg: string, ...args: unknown[]): void {
|
|
65
|
+
this.log("info", msg, args);
|
|
66
|
+
}
|
|
67
|
+
debug(msg: string, ...args: unknown[]): void {
|
|
68
|
+
this.log("debug", msg, args);
|
|
69
|
+
}
|
|
52
70
|
}
|
package/src/mock-server.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import Fastify from "fastify";
|
|
2
2
|
import type { FastifyInstance } from "fastify";
|
|
3
3
|
import type {
|
|
4
|
-
Match,
|
|
4
|
+
Match,
|
|
5
|
+
PendingRule,
|
|
6
|
+
Reply,
|
|
7
|
+
ReplyOptions,
|
|
8
|
+
Resolver,
|
|
9
|
+
Rule,
|
|
10
|
+
RuleHandle,
|
|
11
|
+
RuleSummary,
|
|
12
|
+
SequenceEntry,
|
|
5
13
|
} from "./types.js";
|
|
6
14
|
import { RuleEngine, createSequenceResolver } from "./rule-engine.js";
|
|
7
15
|
import { RequestHistory } from "./history.js";
|
|
@@ -13,7 +21,11 @@ import { Logger } from "./logger.js";
|
|
|
13
21
|
import type { LogLevel } from "./logger.js";
|
|
14
22
|
import { createRouteHandler } from "./route-handler.js";
|
|
15
23
|
|
|
16
|
-
const formats: readonly Format[] = [
|
|
24
|
+
const formats: readonly Format[] = [
|
|
25
|
+
openaiFormat,
|
|
26
|
+
anthropicFormat,
|
|
27
|
+
responsesFormat,
|
|
28
|
+
];
|
|
17
29
|
|
|
18
30
|
export interface MockServerOptions {
|
|
19
31
|
readonly port?: number;
|
|
@@ -56,8 +68,12 @@ export class MockServer {
|
|
|
56
68
|
this.host = options.host ?? "127.0.0.1";
|
|
57
69
|
this.logger = new Logger(options.logLevel ?? "none");
|
|
58
70
|
this.defaultOptions = {
|
|
59
|
-
...(options.defaultLatency !== undefined && {
|
|
60
|
-
|
|
71
|
+
...(options.defaultLatency !== undefined && {
|
|
72
|
+
latency: options.defaultLatency,
|
|
73
|
+
}),
|
|
74
|
+
...(options.defaultChunkSize !== undefined && {
|
|
75
|
+
chunkSize: options.defaultChunkSize,
|
|
76
|
+
}),
|
|
61
77
|
};
|
|
62
78
|
this.app = Fastify({ logger: false });
|
|
63
79
|
|
|
@@ -171,10 +187,14 @@ export class MockServer {
|
|
|
171
187
|
const { loadRulesFromPath } = await import("./loader.js");
|
|
172
188
|
await loadRulesFromPath(pathOrDir, {
|
|
173
189
|
engine: this.engine,
|
|
174
|
-
setFallback: (reply) => {
|
|
190
|
+
setFallback: (reply) => {
|
|
191
|
+
this.fallbackReply = reply;
|
|
192
|
+
},
|
|
175
193
|
});
|
|
176
194
|
const loaded = this.engine.ruleCount - before;
|
|
177
|
-
this.logger.info(
|
|
195
|
+
this.logger.info(
|
|
196
|
+
`Loaded ${loaded} rule${loaded !== 1 ? "s" : ""} from ${pathOrDir}`,
|
|
197
|
+
);
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
/** Every request the server has handled. */
|
|
@@ -197,7 +217,8 @@ export class MockServer {
|
|
|
197
217
|
|
|
198
218
|
/** The base URL the server is listening on, e.g. `http://127.0.0.1:12345`. Throws if the server hasn't started. */
|
|
199
219
|
get url(): string {
|
|
200
|
-
if (!this.listening)
|
|
220
|
+
if (!this.listening)
|
|
221
|
+
throw new Error("Server is not running. Call start() first.");
|
|
201
222
|
const addr = this.app.server.address();
|
|
202
223
|
const port = addr !== null && typeof addr === "object" ? addr.port : 0;
|
|
203
224
|
return `http://${this.host}:${port}`;
|
package/src/route-handler.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
2
|
import { ZodError } from "zod";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Reply,
|
|
5
|
+
ReplyObject,
|
|
6
|
+
ReplyOptions,
|
|
7
|
+
MockRequest,
|
|
8
|
+
Rule,
|
|
9
|
+
} from "./types.js";
|
|
4
10
|
import type { Format } from "./formats/types.js";
|
|
5
11
|
import type { RuleEngine } from "./rule-engine.js";
|
|
6
12
|
import type { RequestHistory } from "./history.js";
|
|
@@ -21,14 +27,17 @@ async function resolveReply(
|
|
|
21
27
|
logger: Logger,
|
|
22
28
|
): Promise<{ reply: ReplyObject; ruleDesc: string | undefined }> {
|
|
23
29
|
if (!matched) {
|
|
24
|
-
logger.warn(
|
|
30
|
+
logger.warn(
|
|
31
|
+
`No matching rule for "${mockReq.lastMessage}", using fallback`,
|
|
32
|
+
);
|
|
25
33
|
return { reply: normalizeReply(fallback), ruleDesc: undefined };
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
try {
|
|
29
|
-
const raw =
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
const raw =
|
|
38
|
+
typeof matched.resolve === "function"
|
|
39
|
+
? await matched.resolve(mockReq)
|
|
40
|
+
: matched.resolve;
|
|
32
41
|
logger.debug(`Matched rule ${matched.description}`);
|
|
33
42
|
return { reply: normalizeReply(raw), ruleDesc: matched.description };
|
|
34
43
|
} catch (err) {
|
|
@@ -45,7 +54,10 @@ interface RouteHandlerDeps {
|
|
|
45
54
|
getFallback: () => Reply;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
export function createRouteHandler(
|
|
57
|
+
export function createRouteHandler(
|
|
58
|
+
format: Format,
|
|
59
|
+
deps: RouteHandlerDeps,
|
|
60
|
+
): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
|
|
49
61
|
const { engine, history, logger, defaultOptions, getFallback } = deps;
|
|
50
62
|
|
|
51
63
|
return async (request: FastifyRequest, reply: FastifyReply) => {
|
|
@@ -61,10 +73,19 @@ export function createRouteHandler(format: Format, deps: RouteHandlerDeps): (req
|
|
|
61
73
|
mockReq = format.parseRequest(body, meta);
|
|
62
74
|
} catch (err) {
|
|
63
75
|
if (err instanceof ZodError) {
|
|
64
|
-
logger.warn(
|
|
65
|
-
|
|
66
|
-
format.serializeError({ status: HTTP_BAD_REQUEST, message: "Invalid request body", type: "invalid_request_error" }),
|
|
76
|
+
logger.warn(
|
|
77
|
+
`Invalid ${format.name} request: ${err.issues.map((i) => i.message).join(", ")}`,
|
|
67
78
|
);
|
|
79
|
+
return reply
|
|
80
|
+
.status(HTTP_BAD_REQUEST)
|
|
81
|
+
.type("application/json")
|
|
82
|
+
.send(
|
|
83
|
+
format.serializeError({
|
|
84
|
+
status: HTTP_BAD_REQUEST,
|
|
85
|
+
message: "Invalid request body",
|
|
86
|
+
type: "invalid_request_error",
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
68
89
|
}
|
|
69
90
|
throw err;
|
|
70
91
|
}
|
|
@@ -76,14 +97,20 @@ export function createRouteHandler(format: Format, deps: RouteHandlerDeps): (req
|
|
|
76
97
|
|
|
77
98
|
const matched = engine.match(mockReq);
|
|
78
99
|
const { reply: resolvedReply, ruleDesc } = await resolveReply(
|
|
79
|
-
matched,
|
|
100
|
+
matched,
|
|
101
|
+
mockReq,
|
|
102
|
+
getFallback(),
|
|
103
|
+
logger,
|
|
80
104
|
);
|
|
81
105
|
|
|
82
106
|
if (resolvedReply.error) {
|
|
83
107
|
const { error } = resolvedReply;
|
|
84
108
|
logger.info(`Error reply: ${String(error.status)} ${error.message}`);
|
|
85
109
|
history.record(mockReq, ruleDesc);
|
|
86
|
-
return reply
|
|
110
|
+
return reply
|
|
111
|
+
.status(error.status)
|
|
112
|
+
.type("application/json")
|
|
113
|
+
.send(format.serializeError(error));
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
history.record(mockReq, ruleDesc);
|
|
@@ -100,14 +127,22 @@ export function createRouteHandler(format: Format, deps: RouteHandlerDeps): (req
|
|
|
100
127
|
logger.debug(`Reply text: "${resolvedReply.text}"`);
|
|
101
128
|
}
|
|
102
129
|
if (resolvedReply.tools?.length) {
|
|
103
|
-
logger.debug(
|
|
130
|
+
logger.debug(
|
|
131
|
+
`Reply tool calls: ${resolvedReply.tools.map((t) => t.name).join(", ")}`,
|
|
132
|
+
);
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
if (!isStreaming) {
|
|
107
|
-
return reply
|
|
136
|
+
return reply
|
|
137
|
+
.type("application/json")
|
|
138
|
+
.send(format.serializeComplete(resolvedReply, mockReq.model));
|
|
108
139
|
}
|
|
109
140
|
|
|
110
|
-
const chunks = format.serialize(
|
|
141
|
+
const chunks = format.serialize(
|
|
142
|
+
resolvedReply,
|
|
143
|
+
mockReq.model,
|
|
144
|
+
effectiveOptions,
|
|
145
|
+
);
|
|
111
146
|
await writeSSE(reply, chunks, effectiveOptions);
|
|
112
147
|
};
|
|
113
148
|
}
|
package/src/rule-engine.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Match,
|
|
3
|
+
MatchObject,
|
|
4
|
+
MockRequest,
|
|
5
|
+
Resolver,
|
|
6
|
+
Reply,
|
|
7
|
+
ReplyOptions,
|
|
8
|
+
Rule,
|
|
9
|
+
RuleSummary,
|
|
10
|
+
} from "./types.js";
|
|
2
11
|
|
|
3
12
|
function safeRegex(re: RegExp): RegExp {
|
|
4
|
-
return
|
|
13
|
+
return re.global || re.sticky
|
|
14
|
+
? new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
|
|
15
|
+
: re;
|
|
5
16
|
}
|
|
6
17
|
|
|
7
18
|
function compilePattern(pattern: string | RegExp): (value: string) => boolean {
|
|
@@ -26,16 +37,21 @@ function compileMatcher(match: Match): (req: MockRequest) => boolean {
|
|
|
26
37
|
return match;
|
|
27
38
|
}
|
|
28
39
|
const obj = match;
|
|
29
|
-
const messageTest =
|
|
30
|
-
|
|
31
|
-
const
|
|
40
|
+
const messageTest =
|
|
41
|
+
obj.message !== undefined ? compilePattern(obj.message) : undefined;
|
|
42
|
+
const modelTest =
|
|
43
|
+
obj.model !== undefined ? compilePattern(obj.model) : undefined;
|
|
44
|
+
const systemTest =
|
|
45
|
+
obj.system !== undefined ? compilePattern(obj.system) : undefined;
|
|
32
46
|
return (req) => {
|
|
33
47
|
if (messageTest && !messageTest(req.lastMessage)) return false;
|
|
34
48
|
if (modelTest && !modelTest(req.model)) return false;
|
|
35
49
|
if (systemTest && !systemTest(req.systemMessage)) return false;
|
|
36
50
|
if (obj.format !== undefined && req.format !== obj.format) return false;
|
|
37
|
-
if (obj.toolName !== undefined && !req.toolNames.includes(obj.toolName))
|
|
38
|
-
|
|
51
|
+
if (obj.toolName !== undefined && !req.toolNames.includes(obj.toolName))
|
|
52
|
+
return false;
|
|
53
|
+
if (obj.toolCallId !== undefined && req.lastToolCallId !== obj.toolCallId)
|
|
54
|
+
return false;
|
|
39
55
|
if (obj.predicate && !obj.predicate(req)) return false;
|
|
40
56
|
return true;
|
|
41
57
|
};
|
|
@@ -52,7 +68,12 @@ function describeMatch(match: Match): string {
|
|
|
52
68
|
return `{${parts.join(", ")}}`;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
|
-
function createRule(
|
|
71
|
+
function createRule(
|
|
72
|
+
match: Match,
|
|
73
|
+
resolve: Resolver,
|
|
74
|
+
options: ReplyOptions,
|
|
75
|
+
description?: string,
|
|
76
|
+
): Rule {
|
|
56
77
|
return {
|
|
57
78
|
description: description ?? describeMatch(match),
|
|
58
79
|
match: compileMatcher(match),
|
|
@@ -71,7 +92,8 @@ export function createSequenceResolver(
|
|
|
71
92
|
steps: readonly SequenceStep[],
|
|
72
93
|
rule: { options: ReplyOptions },
|
|
73
94
|
): { resolver: () => Reply; entryCount: number } {
|
|
74
|
-
if (steps.length === 0)
|
|
95
|
+
if (steps.length === 0)
|
|
96
|
+
throw new Error("Sequence requires at least one entry.");
|
|
75
97
|
let index = 0;
|
|
76
98
|
const last = steps[steps.length - 1]!;
|
|
77
99
|
return {
|
|
@@ -101,7 +123,11 @@ export class RuleEngine {
|
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
125
|
|
|
104
|
-
addHandler(
|
|
126
|
+
addHandler(
|
|
127
|
+
matchFn: (req: MockRequest) => boolean,
|
|
128
|
+
respond: Resolver,
|
|
129
|
+
description = "(handler)",
|
|
130
|
+
): Rule {
|
|
105
131
|
const rule = createRule(matchFn, respond, {}, description);
|
|
106
132
|
this.rules.push(rule);
|
|
107
133
|
return rule;
|
|
@@ -124,7 +150,9 @@ export class RuleEngine {
|
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
isDone(): boolean {
|
|
127
|
-
return this.rules.every(
|
|
153
|
+
return this.rules.every(
|
|
154
|
+
(r) => !Number.isFinite(r.remaining) || r.remaining <= 0,
|
|
155
|
+
);
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
get ruleCount(): number {
|
|
@@ -132,7 +160,10 @@ export class RuleEngine {
|
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
describe(): readonly RuleSummary[] {
|
|
135
|
-
return this.rules.map((r) => ({
|
|
163
|
+
return this.rules.map((r) => ({
|
|
164
|
+
description: r.description,
|
|
165
|
+
remaining: r.remaining,
|
|
166
|
+
}));
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
clear(): void {
|
package/src/types/reply.ts
CHANGED
|
@@ -10,7 +10,9 @@ export interface ReplyObject {
|
|
|
10
10
|
readonly reasoning?: string | undefined;
|
|
11
11
|
readonly tools?: readonly ToolCall[] | undefined;
|
|
12
12
|
/** Falls back to `{ input: 10, output: 5 }` if omitted. */
|
|
13
|
-
readonly usage?:
|
|
13
|
+
readonly usage?:
|
|
14
|
+
| { readonly input: number; readonly output: number }
|
|
15
|
+
| undefined;
|
|
14
16
|
/** When set, the server responds with this HTTP error instead of a normal reply. */
|
|
15
17
|
readonly error?: ErrorReply | undefined;
|
|
16
18
|
}
|
|
@@ -42,4 +44,6 @@ export interface ReplyOptions {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** A single entry in a reply sequence. Can be a plain reply or a reply with per-step options. */
|
|
45
|
-
export type SequenceEntry =
|
|
47
|
+
export type SequenceEntry =
|
|
48
|
+
| Reply
|
|
49
|
+
| { readonly reply: Reply; readonly options?: ReplyOptions };
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
export type {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export type {
|
|
2
|
+
FormatName,
|
|
3
|
+
MockRequest,
|
|
4
|
+
Message,
|
|
5
|
+
ToolDef,
|
|
6
|
+
} from "./types/request.js";
|
|
7
|
+
export type {
|
|
8
|
+
Reply,
|
|
9
|
+
ReplyObject,
|
|
10
|
+
ErrorReply,
|
|
11
|
+
ToolCall,
|
|
12
|
+
Resolver,
|
|
13
|
+
ReplyOptions,
|
|
14
|
+
SequenceEntry,
|
|
15
|
+
} from "./types/reply.js";
|
|
16
|
+
export type {
|
|
17
|
+
Match,
|
|
18
|
+
MatchObject,
|
|
19
|
+
PendingRule,
|
|
20
|
+
RuleHandle,
|
|
21
|
+
RuleSummary,
|
|
22
|
+
Handler,
|
|
23
|
+
Rule,
|
|
24
|
+
} from "./types/rule.js";
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parsePort,
|
|
4
|
+
parseHost,
|
|
5
|
+
parseChunkSize,
|
|
6
|
+
parseLogLevel,
|
|
7
|
+
parseLatency,
|
|
8
|
+
} from "../src/cli-validators.js";
|
|
3
9
|
|
|
4
10
|
describe("parsePort", () => {
|
|
5
11
|
it("parses a valid port", () => {
|
|
@@ -48,7 +54,9 @@ describe("parseLogLevel", () => {
|
|
|
48
54
|
);
|
|
49
55
|
|
|
50
56
|
it("throws on invalid level", () => {
|
|
51
|
-
expect(() => parseLogLevel("verbose")).toThrow(
|
|
57
|
+
expect(() => parseLogLevel("verbose")).toThrow(
|
|
58
|
+
'Invalid log level "verbose"',
|
|
59
|
+
);
|
|
52
60
|
});
|
|
53
61
|
|
|
54
62
|
it("throws on empty string", () => {
|
|
@@ -78,11 +86,15 @@ describe("parseHost", () => {
|
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
it("rejects an unresolvable hostname", async () => {
|
|
81
|
-
await expect(parseHost("not.a.real.host.invalid")).rejects.toThrow(
|
|
89
|
+
await expect(parseHost("not.a.real.host.invalid")).rejects.toThrow(
|
|
90
|
+
"Invalid host",
|
|
91
|
+
);
|
|
82
92
|
});
|
|
83
93
|
|
|
84
94
|
it("rejects a string with spaces", async () => {
|
|
85
|
-
await expect(parseHost("local host")).rejects.toThrow(
|
|
95
|
+
await expect(parseHost("local host")).rejects.toThrow(
|
|
96
|
+
'Invalid host "local host"',
|
|
97
|
+
);
|
|
86
98
|
});
|
|
87
99
|
});
|
|
88
100
|
|