llm-mock-server 1.0.1 → 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/.claude/skills/desloppify/SKILL.md +308 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000801.json +242 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000905.json +248 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000917.json +248 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/canonical_import_20260315_000950.json +311 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/claude_launch_prompt.md +17 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/review_result.json +255 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/review_result.template.json +22 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/reviewer_instructions.md +20 -0
- package/.desloppify/external_review_sessions/ext_20260315_000339_a6cdc3e6/session.json +20 -0
- package/.desloppify/query.json +284 -0
- package/.desloppify/review_packet_blind.json +1303 -0
- package/.desloppify/review_packets/holistic_packet_20260315_000339.json +1471 -0
- package/.desloppify/state-typescript.json +5114 -0
- package/.desloppify/state-typescript.json.bak +5108 -0
- package/.editorconfig +12 -0
- package/.github/workflows/test.yml +3 -0
- package/.oxfmtrc.json +9 -0
- package/dist/cli.js +5 -2
- package/dist/cli.js.map +1 -1
- package/dist/formats/anthropic/index.js +1 -1
- package/dist/formats/anthropic/index.js.map +1 -1
- package/dist/formats/anthropic/parse.d.ts +1 -1
- package/dist/formats/anthropic/parse.d.ts.map +1 -1
- package/dist/formats/anthropic/parse.js +1 -1
- package/dist/formats/anthropic/parse.js.map +1 -1
- package/dist/formats/anthropic/serialize.d.ts +2 -2
- package/dist/formats/anthropic/serialize.d.ts.map +1 -1
- package/dist/formats/anthropic/serialize.js +6 -3
- package/dist/formats/anthropic/serialize.js.map +1 -1
- package/dist/formats/openai/index.js +1 -1
- package/dist/formats/openai/index.js.map +1 -1
- package/dist/formats/openai/parse.d.ts +1 -1
- package/dist/formats/openai/parse.d.ts.map +1 -1
- package/dist/formats/openai/parse.js +1 -1
- package/dist/formats/openai/parse.js.map +1 -1
- package/dist/formats/openai/serialize.d.ts +2 -2
- package/dist/formats/openai/serialize.d.ts.map +1 -1
- package/dist/formats/openai/serialize.js +12 -15
- package/dist/formats/openai/serialize.js.map +1 -1
- package/dist/formats/request-helpers.d.ts +13 -0
- package/dist/formats/request-helpers.d.ts.map +1 -0
- package/dist/formats/request-helpers.js +28 -0
- package/dist/formats/request-helpers.js.map +1 -0
- package/dist/formats/responses/index.js +1 -1
- package/dist/formats/responses/index.js.map +1 -1
- package/dist/formats/responses/parse.d.ts +1 -1
- package/dist/formats/responses/parse.d.ts.map +1 -1
- package/dist/formats/responses/parse.js +1 -1
- package/dist/formats/responses/parse.js.map +1 -1
- package/dist/formats/responses/schema.d.ts +1 -20
- package/dist/formats/responses/schema.d.ts.map +1 -1
- package/dist/formats/responses/schema.js.map +1 -1
- package/dist/formats/responses/serialize.d.ts +2 -2
- package/dist/formats/responses/serialize.d.ts.map +1 -1
- package/dist/formats/responses/serialize.js +6 -3
- package/dist/formats/responses/serialize.js.map +1 -1
- package/dist/formats/serialize-helpers.d.ts +14 -0
- package/dist/formats/serialize-helpers.d.ts.map +1 -0
- package/dist/formats/serialize-helpers.js +25 -0
- package/dist/formats/serialize-helpers.js.map +1 -0
- package/dist/formats/types.d.ts +3 -3
- package/dist/formats/types.d.ts.map +1 -1
- package/dist/loader.d.ts +3 -2
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +6 -9
- package/dist/loader.js.map +1 -1
- package/dist/logger.d.ts +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +17 -23
- package/dist/logger.js.map +1 -1
- package/dist/mock-server.d.ts.map +1 -1
- package/dist/mock-server.js +8 -15
- package/dist/mock-server.js.map +1 -1
- package/dist/route-handler.d.ts +2 -1
- package/dist/route-handler.d.ts.map +1 -1
- package/dist/rule-engine.d.ts +12 -1
- package/dist/rule-engine.d.ts.map +1 -1
- package/dist/rule-engine.js +14 -0
- package/dist/rule-engine.js.map +1 -1
- package/dist/types/reply.d.ts +6 -10
- package/dist/types/reply.d.ts.map +1 -1
- package/dist/types/request.d.ts +7 -11
- package/dist/types/request.d.ts.map +1 -1
- package/dist/types/rule.d.ts +3 -10
- package/dist/types/rule.d.ts.map +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/scorecard.png +0 -0
- package/src/cli-validators.ts +12 -4
- package/src/cli.ts +27 -7
- package/src/formats/anthropic/index.ts +1 -1
- package/src/formats/anthropic/parse.ts +25 -6
- package/src/formats/anthropic/schema.ts +16 -8
- package/src/formats/anthropic/serialize.ts +116 -28
- package/src/formats/openai/index.ts +1 -1
- package/src/formats/openai/parse.ts +13 -3
- package/src/formats/openai/schema.ts +43 -30
- package/src/formats/openai/serialize.ts +84 -30
- package/src/formats/{parse-helpers.ts → request-helpers.ts} +4 -32
- package/src/formats/responses/index.ts +1 -1
- package/src/formats/responses/parse.ts +18 -4
- package/src/formats/responses/schema.ts +34 -22
- package/src/formats/responses/serialize.ts +237 -38
- package/src/formats/serialize-helpers.ts +38 -0
- package/src/formats/types.ts +18 -5
- package/src/index.ts +3 -1
- package/src/loader.ts +43 -20
- package/src/logger.ts +31 -19
- package/src/mock-server.ts +38 -21
- package/src/route-handler.ts +50 -15
- package/src/rule-engine.ts +64 -11
- package/src/types/reply.ts +12 -12
- package/src/types/request.ts +7 -11
- package/src/types/rule.ts +3 -10
- package/src/types.ts +23 -4
- package/test/cli-validators.test.ts +16 -4
- package/test/formats/anthropic.test.ts +84 -23
- package/test/formats/openai.test.ts +85 -24
- package/test/formats/parse-helpers.test.ts +315 -0
- package/test/formats/responses.test.ts +99 -34
- package/test/helpers/make-req.ts +18 -0
- package/test/history.test.ts +361 -0
- package/test/loader.test.ts +44 -45
- package/test/logger.test.ts +344 -0
- package/test/mock-server.test.ts +77 -23
- package/test/rule-engine.test.ts +57 -41
- package/src/types/index.ts +0 -4
package/src/mock-server.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
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
|
-
import { RuleEngine } from "./rule-engine.js";
|
|
14
|
+
import { RuleEngine, createSequenceResolver } from "./rule-engine.js";
|
|
7
15
|
import { RequestHistory } from "./history.js";
|
|
8
16
|
import { openaiFormat } from "./formats/openai/index.js";
|
|
9
17
|
import { anthropicFormat } from "./formats/anthropic/index.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
|
|
|
@@ -103,19 +119,15 @@ export class MockServer {
|
|
|
103
119
|
return makeHandle(engine.add(match, response, options));
|
|
104
120
|
},
|
|
105
121
|
replySequence(entries: readonly SequenceEntry[]): RuleHandle {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
rule.options = entry.options ?? {};
|
|
116
|
-
return entry.reply;
|
|
117
|
-
});
|
|
118
|
-
rule.remaining = entries.length;
|
|
122
|
+
const steps = entries.map((entry) =>
|
|
123
|
+
typeof entry === "string" || !("reply" in entry)
|
|
124
|
+
? { reply: entry as Reply }
|
|
125
|
+
: { reply: entry.reply, options: entry.options },
|
|
126
|
+
);
|
|
127
|
+
const rule = engine.add(match, "");
|
|
128
|
+
const { resolver, entryCount } = createSequenceResolver(steps, rule);
|
|
129
|
+
rule.resolve = resolver;
|
|
130
|
+
rule.remaining = entryCount;
|
|
119
131
|
return makeHandle(rule);
|
|
120
132
|
},
|
|
121
133
|
};
|
|
@@ -175,10 +187,14 @@ export class MockServer {
|
|
|
175
187
|
const { loadRulesFromPath } = await import("./loader.js");
|
|
176
188
|
await loadRulesFromPath(pathOrDir, {
|
|
177
189
|
engine: this.engine,
|
|
178
|
-
setFallback: (reply) => {
|
|
190
|
+
setFallback: (reply) => {
|
|
191
|
+
this.fallbackReply = reply;
|
|
192
|
+
},
|
|
179
193
|
});
|
|
180
194
|
const loaded = this.engine.ruleCount - before;
|
|
181
|
-
this.logger.info(
|
|
195
|
+
this.logger.info(
|
|
196
|
+
`Loaded ${loaded} rule${loaded !== 1 ? "s" : ""} from ${pathOrDir}`,
|
|
197
|
+
);
|
|
182
198
|
}
|
|
183
199
|
|
|
184
200
|
/** Every request the server has handled. */
|
|
@@ -201,7 +217,8 @@ export class MockServer {
|
|
|
201
217
|
|
|
202
218
|
/** The base URL the server is listening on, e.g. `http://127.0.0.1:12345`. Throws if the server hasn't started. */
|
|
203
219
|
get url(): string {
|
|
204
|
-
if (!this.listening)
|
|
220
|
+
if (!this.listening)
|
|
221
|
+
throw new Error("Server is not running. Call start() first.");
|
|
205
222
|
const addr = this.app.server.address();
|
|
206
223
|
const port = addr !== null && typeof addr === "object" ? addr.port : 0;
|
|
207
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) {
|
|
@@ -37,7 +46,7 @@ async function resolveReply(
|
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
interface RouteHandlerDeps {
|
|
41
50
|
engine: RuleEngine;
|
|
42
51
|
history: RequestHistory;
|
|
43
52
|
logger: Logger;
|
|
@@ -45,7 +54,10 @@ export 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),
|
|
@@ -62,6 +83,29 @@ function createRule(match: Match, resolve: Resolver, options: ReplyOptions, desc
|
|
|
62
83
|
};
|
|
63
84
|
}
|
|
64
85
|
|
|
86
|
+
interface SequenceStep {
|
|
87
|
+
readonly reply: Reply;
|
|
88
|
+
readonly options?: ReplyOptions | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSequenceResolver(
|
|
92
|
+
steps: readonly SequenceStep[],
|
|
93
|
+
rule: { options: ReplyOptions },
|
|
94
|
+
): { resolver: () => Reply; entryCount: number } {
|
|
95
|
+
if (steps.length === 0)
|
|
96
|
+
throw new Error("Sequence requires at least one entry.");
|
|
97
|
+
let index = 0;
|
|
98
|
+
const last = steps[steps.length - 1]!;
|
|
99
|
+
return {
|
|
100
|
+
resolver: () => {
|
|
101
|
+
const step = steps[index++] ?? last;
|
|
102
|
+
rule.options = step.options ?? {};
|
|
103
|
+
return step.reply;
|
|
104
|
+
},
|
|
105
|
+
entryCount: steps.length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
export class RuleEngine {
|
|
66
110
|
private readonly rules: Rule[] = [];
|
|
67
111
|
|
|
@@ -79,7 +123,11 @@ export class RuleEngine {
|
|
|
79
123
|
}
|
|
80
124
|
}
|
|
81
125
|
|
|
82
|
-
addHandler(
|
|
126
|
+
addHandler(
|
|
127
|
+
matchFn: (req: MockRequest) => boolean,
|
|
128
|
+
respond: Resolver,
|
|
129
|
+
description = "(handler)",
|
|
130
|
+
): Rule {
|
|
83
131
|
const rule = createRule(matchFn, respond, {}, description);
|
|
84
132
|
this.rules.push(rule);
|
|
85
133
|
return rule;
|
|
@@ -102,7 +150,9 @@ export class RuleEngine {
|
|
|
102
150
|
}
|
|
103
151
|
|
|
104
152
|
isDone(): boolean {
|
|
105
|
-
return this.rules.every(
|
|
153
|
+
return this.rules.every(
|
|
154
|
+
(r) => !Number.isFinite(r.remaining) || r.remaining <= 0,
|
|
155
|
+
);
|
|
106
156
|
}
|
|
107
157
|
|
|
108
158
|
get ruleCount(): number {
|
|
@@ -110,7 +160,10 @@ export class RuleEngine {
|
|
|
110
160
|
}
|
|
111
161
|
|
|
112
162
|
describe(): readonly RuleSummary[] {
|
|
113
|
-
return this.rules.map((r) => ({
|
|
163
|
+
return this.rules.map((r) => ({
|
|
164
|
+
description: r.description,
|
|
165
|
+
remaining: r.remaining,
|
|
166
|
+
}));
|
|
114
167
|
}
|
|
115
168
|
|
|
116
169
|
clear(): void {
|
package/src/types/reply.ts
CHANGED
|
@@ -5,30 +5,28 @@ export type Reply = string | ReplyObject;
|
|
|
5
5
|
|
|
6
6
|
/** A structured reply. Text, reasoning, tool calls, usage, and errors are all optional. */
|
|
7
7
|
export interface ReplyObject {
|
|
8
|
-
/** Text content to send back. */
|
|
9
8
|
readonly text?: string | undefined;
|
|
10
9
|
/** Extended thinking or chain-of-thought. Works with Anthropic and Responses formats. */
|
|
11
10
|
readonly reasoning?: string | undefined;
|
|
12
|
-
/** Tool calls the "model" wants to make. */
|
|
13
11
|
readonly tools?: readonly ToolCall[] | undefined;
|
|
14
|
-
/**
|
|
15
|
-
readonly usage?:
|
|
12
|
+
/** Falls back to `{ input: 10, output: 5 }` if omitted. */
|
|
13
|
+
readonly usage?:
|
|
14
|
+
| { readonly input: number; readonly output: number }
|
|
15
|
+
| undefined;
|
|
16
16
|
/** When set, the server responds with this HTTP error instead of a normal reply. */
|
|
17
17
|
readonly error?: ErrorReply | undefined;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** An HTTP error response. The server returns this status code with a format-appropriate body. */
|
|
21
21
|
export interface ErrorReply {
|
|
22
|
-
/** HTTP status code, like 429 or 500. */
|
|
23
22
|
readonly status: number;
|
|
24
23
|
readonly message: string;
|
|
25
|
-
/**
|
|
24
|
+
/** Each format has its own default if omitted. */
|
|
26
25
|
readonly type?: string | undefined;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
/** A tool call in the mock response. */
|
|
30
28
|
export interface ToolCall {
|
|
31
|
-
/**
|
|
29
|
+
/** Auto-generated if omitted. */
|
|
32
30
|
readonly id?: string | undefined;
|
|
33
31
|
readonly name: string;
|
|
34
32
|
readonly args: Readonly<Record<string, unknown>>;
|
|
@@ -37,13 +35,15 @@ export interface ToolCall {
|
|
|
37
35
|
/** A reply value or a function that produces one. Async functions are supported. */
|
|
38
36
|
export type Resolver = Reply | ((req: MockRequest) => Reply | Promise<Reply>);
|
|
39
37
|
|
|
40
|
-
/**
|
|
38
|
+
/** Per-rule streaming options. Merged with server-level defaults, with per-rule values winning. */
|
|
41
39
|
export interface ReplyOptions {
|
|
42
|
-
/** Milliseconds
|
|
40
|
+
/** Milliseconds between SSE chunks. */
|
|
43
41
|
readonly latency?: number | undefined;
|
|
44
|
-
/**
|
|
42
|
+
/** Characters per SSE chunk for more realistic streaming. */
|
|
45
43
|
readonly chunkSize?: number | undefined;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
/** A single entry in a reply sequence. Can be a plain reply or a reply with per-step options. */
|
|
49
|
-
export type SequenceEntry =
|
|
47
|
+
export type SequenceEntry =
|
|
48
|
+
| Reply
|
|
49
|
+
| { readonly reply: Reply; readonly options?: ReplyOptions };
|
package/src/types/request.ts
CHANGED
|
@@ -3,28 +3,24 @@ export type FormatName = "openai" | "anthropic" | "responses";
|
|
|
3
3
|
|
|
4
4
|
/** A normalised view of an incoming request, regardless of the original wire format. */
|
|
5
5
|
export interface MockRequest {
|
|
6
|
-
/** Which format route the request hit. */
|
|
7
6
|
readonly format: FormatName;
|
|
8
7
|
readonly model: string;
|
|
9
|
-
/** Whether the client asked for SSE streaming. */
|
|
10
8
|
readonly streaming: boolean;
|
|
11
9
|
/** Full conversation, normalised from whatever format came in. */
|
|
12
10
|
readonly messages: readonly Message[];
|
|
13
11
|
/** The last user message's text. This is what most matchers check. */
|
|
14
12
|
readonly lastMessage: string;
|
|
15
|
-
/**
|
|
13
|
+
/** Empty string if there wasn't one. */
|
|
16
14
|
readonly systemMessage: string;
|
|
17
|
-
/** Tool definitions from the request, if any were sent. */
|
|
18
15
|
readonly tools?: readonly ToolDef[] | undefined;
|
|
19
|
-
/**
|
|
16
|
+
/** Pulled out from `tools` for quick lookups. */
|
|
20
17
|
readonly toolNames: readonly string[];
|
|
21
|
-
/**
|
|
18
|
+
/** Set when the last message was a tool result. */
|
|
22
19
|
readonly lastToolCallId: string | undefined;
|
|
23
|
-
/** The raw request body,
|
|
20
|
+
/** The raw request body, for anything we don't extract. */
|
|
24
21
|
readonly raw: unknown;
|
|
25
|
-
/** HTTP headers from the incoming request. */
|
|
26
22
|
readonly headers: Readonly<Record<string, string | undefined>>;
|
|
27
|
-
/**
|
|
23
|
+
/** e.g. `/v1/chat/completions` */
|
|
28
24
|
readonly path: string;
|
|
29
25
|
}
|
|
30
26
|
|
|
@@ -32,7 +28,7 @@ export interface MockRequest {
|
|
|
32
28
|
export interface Message {
|
|
33
29
|
readonly role: "system" | "user" | "assistant" | "tool";
|
|
34
30
|
readonly content: string;
|
|
35
|
-
/**
|
|
31
|
+
/** Links the result back to its tool call. Only set on `"tool"` messages. */
|
|
36
32
|
readonly toolCallId?: string | undefined;
|
|
37
33
|
}
|
|
38
34
|
|
|
@@ -40,6 +36,6 @@ export interface Message {
|
|
|
40
36
|
export interface ToolDef {
|
|
41
37
|
readonly name: string;
|
|
42
38
|
readonly description?: string | undefined;
|
|
43
|
-
/** JSON Schema
|
|
39
|
+
/** JSON Schema, passed through as-is. */
|
|
44
40
|
readonly parameters?: unknown;
|
|
45
41
|
}
|
package/src/types/rule.ts
CHANGED
|
@@ -17,13 +17,9 @@ export type Match =
|
|
|
17
17
|
|
|
18
18
|
/** A structured matcher. Every field you set must match for the rule to fire. */
|
|
19
19
|
export interface MatchObject {
|
|
20
|
-
/** Substring or regex against the last user message. */
|
|
21
20
|
readonly message?: string | RegExp;
|
|
22
|
-
/** Substring or regex against the model name. */
|
|
23
21
|
readonly model?: string | RegExp;
|
|
24
|
-
/** Substring or regex against the system prompt. */
|
|
25
22
|
readonly system?: string | RegExp;
|
|
26
|
-
/** Only match requests from this API format. */
|
|
27
23
|
readonly format?: FormatName;
|
|
28
24
|
/** Match when the request includes a tool definition with this name. */
|
|
29
25
|
readonly toolName?: string;
|
|
@@ -36,15 +32,13 @@ export interface MatchObject {
|
|
|
36
32
|
/** Returned by `when()`. Call `.reply()` or `.replySequence()` on it to complete the rule. */
|
|
37
33
|
export interface PendingRule {
|
|
38
34
|
reply(response: Resolver, options?: ReplyOptions): RuleHandle;
|
|
39
|
-
/** Each match advances through the array. The last entry repeats once
|
|
35
|
+
/** Each match advances through the array. The last entry repeats once exhausted. */
|
|
40
36
|
replySequence(entries: readonly SequenceEntry[]): RuleHandle;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
/** A handle to a registered rule. All methods return `this` for chaining. */
|
|
44
40
|
export interface RuleHandle {
|
|
45
|
-
/** Auto-expire the rule after `n` matches. */
|
|
46
41
|
times(n: number): RuleHandle;
|
|
47
|
-
/** Move this rule to the front of the list so it matches first. */
|
|
48
42
|
first(): RuleHandle;
|
|
49
43
|
}
|
|
50
44
|
|
|
@@ -59,16 +53,15 @@ export interface Handler {
|
|
|
59
53
|
|
|
60
54
|
/** A summary of a registered rule, for inspection. */
|
|
61
55
|
export interface RuleSummary {
|
|
62
|
-
/** Human-readable description of what the rule matches. */
|
|
63
56
|
readonly description: string;
|
|
64
|
-
/**
|
|
57
|
+
/** `Infinity` means unlimited. */
|
|
65
58
|
readonly remaining: number;
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
export interface Rule {
|
|
69
62
|
readonly description: string;
|
|
70
63
|
readonly match: (req: MockRequest) => boolean;
|
|
71
|
-
|
|
64
|
+
resolve: Resolver;
|
|
72
65
|
options: ReplyOptions;
|
|
73
66
|
remaining: number;
|
|
74
67
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
export type {
|
|
2
|
-
FormatName,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|