llm-mock-server 1.0.0

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.
Files changed (51) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/test.yml +34 -0
  3. package/.markdownlint.jsonc +11 -0
  4. package/.node-version +1 -0
  5. package/.oxlintrc.json +35 -0
  6. package/ARCHITECTURE.md +125 -0
  7. package/LICENCE +21 -0
  8. package/README.md +448 -0
  9. package/package.json +55 -0
  10. package/src/cli-validators.ts +56 -0
  11. package/src/cli.ts +128 -0
  12. package/src/formats/anthropic/index.ts +14 -0
  13. package/src/formats/anthropic/parse.ts +48 -0
  14. package/src/formats/anthropic/schema.ts +133 -0
  15. package/src/formats/anthropic/serialize.ts +91 -0
  16. package/src/formats/openai/index.ts +14 -0
  17. package/src/formats/openai/parse.ts +34 -0
  18. package/src/formats/openai/schema.ts +147 -0
  19. package/src/formats/openai/serialize.ts +92 -0
  20. package/src/formats/parse-helpers.ts +79 -0
  21. package/src/formats/responses/index.ts +14 -0
  22. package/src/formats/responses/parse.ts +56 -0
  23. package/src/formats/responses/schema.ts +143 -0
  24. package/src/formats/responses/serialize.ts +129 -0
  25. package/src/formats/types.ts +17 -0
  26. package/src/history.ts +66 -0
  27. package/src/index.ts +44 -0
  28. package/src/loader.ts +213 -0
  29. package/src/logger.ts +58 -0
  30. package/src/mock-server.ts +237 -0
  31. package/src/route-handler.ts +113 -0
  32. package/src/rule-engine.ts +119 -0
  33. package/src/sse-writer.ts +35 -0
  34. package/src/types/index.ts +4 -0
  35. package/src/types/reply.ts +49 -0
  36. package/src/types/request.ts +45 -0
  37. package/src/types/rule.ts +74 -0
  38. package/src/types.ts +5 -0
  39. package/test/cli-validators.test.ts +131 -0
  40. package/test/formats/anthropic-schema.test.ts +192 -0
  41. package/test/formats/anthropic.test.ts +260 -0
  42. package/test/formats/openai-schema.test.ts +105 -0
  43. package/test/formats/openai.test.ts +243 -0
  44. package/test/formats/responses-schema.test.ts +114 -0
  45. package/test/formats/responses.test.ts +299 -0
  46. package/test/loader.test.ts +314 -0
  47. package/test/mock-server.test.ts +565 -0
  48. package/test/rule-engine.test.ts +213 -0
  49. package/tsconfig.json +26 -0
  50. package/tsconfig.test.json +11 -0
  51. package/vitest.config.ts +18 -0
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { writeFile, mkdir, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { RuleEngine } from "../src/rule-engine.js";
5
+ import { loadRulesFromPath } from "../src/loader.js";
6
+ import type { MockRequest } from "../src/types.js";
7
+
8
+ const tmpDir = join(import.meta.dirname, ".tmp-loader-test");
9
+
10
+ function makeReq(overrides: Partial<MockRequest> = {}): MockRequest {
11
+ return {
12
+ format: "openai",
13
+ model: "gpt-5.4",
14
+ streaming: true,
15
+ messages: [{ role: "user", content: "hello" }],
16
+ lastMessage: "hello",
17
+ systemMessage: "",
18
+ toolNames: [],
19
+ lastToolCallId: undefined,
20
+ raw: {},
21
+ headers: {},
22
+ path: "/v1/chat/completions",
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe("Loader", () => {
28
+ let engine: RuleEngine;
29
+
30
+ beforeEach(async () => {
31
+ engine = new RuleEngine();
32
+ await mkdir(tmpDir, { recursive: true });
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await rm(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("JSON5 files", () => {
40
+ it("loads rules from a .json5 file", async () => {
41
+ const rulesPath = join(tmpDir, "rules.json5");
42
+ await writeFile(
43
+ rulesPath,
44
+ `[
45
+ {
46
+ when: "explain recursion",
47
+ reply: "A function that calls itself.",
48
+ },
49
+ {
50
+ when: { model: "gpt-5.4", message: "hello" },
51
+ reply: "Hi from GPT-5.4!",
52
+ },
53
+ ]`,
54
+ );
55
+
56
+ await loadRulesFromPath(rulesPath, { engine });
57
+ expect(engine.ruleCount).toBe(2);
58
+
59
+ const match1 = engine.match(makeReq({ lastMessage: "Please explain recursion" }));
60
+ expect(match1).toBeDefined();
61
+ expect(match1!.resolve).toBe("A function that calls itself.");
62
+
63
+ const match2 = engine.match(makeReq({ model: "gpt-5.4", lastMessage: "hello" }));
64
+ expect(match2).toBeDefined();
65
+ });
66
+
67
+ it("loads regex patterns from JSON5", async () => {
68
+ const rulesPath = join(tmpDir, "regex.json5");
69
+ await writeFile(
70
+ rulesPath,
71
+ `[
72
+ {
73
+ when: "/explain (\\\\w+)/i",
74
+ reply: "Here is an explanation.",
75
+ },
76
+ ]`,
77
+ );
78
+
79
+ await loadRulesFromPath(rulesPath, { engine });
80
+ const match = engine.match(makeReq({ lastMessage: "explain polymorphism" }));
81
+ expect(match).toBeDefined();
82
+ });
83
+
84
+ it("loads regex patterns with modern flags (d, v)", async () => {
85
+ const rulesPath = join(tmpDir, "flags.json5");
86
+ await writeFile(
87
+ rulesPath,
88
+ `[{ when: "/hello/di", reply: "With d flag" }]`,
89
+ );
90
+
91
+ await loadRulesFromPath(rulesPath, { engine });
92
+ expect(engine.match(makeReq({ lastMessage: "hello world" }))).toBeDefined();
93
+ });
94
+
95
+ it("loads rules with times", async () => {
96
+ const rulesPath = join(tmpDir, "times.json5");
97
+ await writeFile(
98
+ rulesPath,
99
+ `[{ when: "once", reply: "One time!", times: 1 }]`,
100
+ );
101
+
102
+ await loadRulesFromPath(rulesPath, { engine });
103
+ expect(engine.match(makeReq({ lastMessage: "once" }))).toBeDefined();
104
+ expect(engine.match(makeReq({ lastMessage: "once" }))).toBeUndefined();
105
+ });
106
+
107
+ it("resolves $template references", async () => {
108
+ const rulesPath = join(tmpDir, "templates.json5");
109
+ await writeFile(
110
+ rulesPath,
111
+ `{
112
+ templates: {
113
+ greeting: "Hello from template!",
114
+ toolReply: { tools: [{ name: "search", args: { q: "test" } }] },
115
+ },
116
+ rules: [
117
+ { when: "hi", reply: "$greeting" },
118
+ { when: "search", reply: "$toolReply" },
119
+ ],
120
+ }`,
121
+ );
122
+
123
+ await loadRulesFromPath(rulesPath, { engine });
124
+ expect(engine.ruleCount).toBe(2);
125
+
126
+ const match1 = engine.match(makeReq({ lastMessage: "hi" }));
127
+ expect(match1?.resolve).toBe("Hello from template!");
128
+
129
+ const match2 = engine.match(makeReq({ lastMessage: "search" }));
130
+ expect(match2?.resolve).toMatchObject({ tools: [{ name: "search" }] });
131
+ });
132
+
133
+ it("throws on unknown template reference", async () => {
134
+ const rulesPath = join(tmpDir, "bad-ref.json5");
135
+ await writeFile(
136
+ rulesPath,
137
+ `{
138
+ rules: [{ when: "hi", reply: "$nonexistent" }],
139
+ }`,
140
+ );
141
+
142
+ await expect(loadRulesFromPath(rulesPath, { engine })).rejects.toThrow("Unknown template");
143
+ });
144
+
145
+ it("loads a replies sequence", async () => {
146
+ const rulesPath = join(tmpDir, "seq.json5");
147
+ await writeFile(
148
+ rulesPath,
149
+ `[{ when: "step", replies: ["First.", "Second."] }]`,
150
+ );
151
+
152
+ await loadRulesFromPath(rulesPath, { engine });
153
+ expect(engine.ruleCount).toBe(1);
154
+
155
+ const req = makeReq({ lastMessage: "step" });
156
+ const match1 = engine.match(req);
157
+ expect(match1).toBeDefined();
158
+ expect((match1!.resolve as () => string)()).toBe("First.");
159
+
160
+ const match2 = engine.match(req);
161
+ expect(match2).toBeDefined();
162
+ expect((match2!.resolve as () => string)()).toBe("Second.");
163
+
164
+ expect(engine.match(req)).toBeUndefined();
165
+ });
166
+
167
+ it("loads fallback from JSON5 file", async () => {
168
+ const rulesPath = join(tmpDir, "fb.json5");
169
+ await writeFile(
170
+ rulesPath,
171
+ `{
172
+ fallback: "Default reply.",
173
+ rules: [{ when: "hi", reply: "Hello!" }],
174
+ }`,
175
+ );
176
+
177
+ let capturedFallback: unknown;
178
+ await loadRulesFromPath(rulesPath, {
179
+ engine,
180
+ setFallback: (reply) => { capturedFallback = reply; },
181
+ });
182
+
183
+ expect(capturedFallback).toBe("Default reply.");
184
+ expect(engine.ruleCount).toBe(1);
185
+ });
186
+ });
187
+
188
+ describe("handler files", () => {
189
+ it("loads a single handler from a .ts file", async () => {
190
+ const handlerPath = join(tmpDir, "single.ts");
191
+ await writeFile(
192
+ handlerPath,
193
+ `export default {
194
+ match: (req) => req.lastMessage.includes("summarize"),
195
+ respond: (req) => "Here is a summary.",
196
+ };`,
197
+ );
198
+
199
+ await loadRulesFromPath(handlerPath, { engine });
200
+ expect(engine.ruleCount).toBe(1);
201
+
202
+ const match = engine.match(makeReq({ lastMessage: "summarize this article" }));
203
+ expect(match).toBeDefined();
204
+ expect(match!.resolve).toBeTypeOf("function");
205
+ });
206
+
207
+ it("loads an array of handlers from a .ts file", async () => {
208
+ const handlerPath = join(tmpDir, "multi.ts");
209
+ await writeFile(
210
+ handlerPath,
211
+ `export default [
212
+ {
213
+ match: (req) => req.lastMessage.includes("hello"),
214
+ respond: () => "Hi!",
215
+ },
216
+ {
217
+ match: (req) => req.lastMessage.includes("bye"),
218
+ respond: () => "Goodbye!",
219
+ },
220
+ ];`,
221
+ );
222
+
223
+ await loadRulesFromPath(handlerPath, { engine });
224
+ expect(engine.ruleCount).toBe(2);
225
+
226
+ expect(engine.match(makeReq({ lastMessage: "hello" }))).toBeDefined();
227
+ expect(engine.match(makeReq({ lastMessage: "bye" }))).toBeDefined();
228
+ expect(engine.match(makeReq({ lastMessage: "nothing" }))).toBeUndefined();
229
+ });
230
+
231
+ it("handler respond function receives the request", async () => {
232
+ const handlerPath = join(tmpDir, "dynamic.ts");
233
+ await writeFile(
234
+ handlerPath,
235
+ `export default {
236
+ match: (req) => req.lastMessage.includes("echo"),
237
+ respond: (req) => \`Echo: \${req.lastMessage}\`,
238
+ };`,
239
+ );
240
+
241
+ await loadRulesFromPath(handlerPath, { engine });
242
+ const rule = engine.match(makeReq({ lastMessage: "echo this" }));
243
+ expect(rule).toBeDefined();
244
+
245
+ const resolver = rule!.resolve as (req: MockRequest) => string;
246
+ const result = resolver(makeReq({ lastMessage: "echo this" }));
247
+ expect(result).toBe("Echo: echo this");
248
+ });
249
+
250
+ it("throws on invalid handler file (missing match/respond)", async () => {
251
+ const handlerPath = join(tmpDir, "bad.ts");
252
+ await writeFile(handlerPath, `export default { mach: () => true, respond: () => "hi" };`);
253
+
254
+ await expect(loadRulesFromPath(handlerPath, { engine })).rejects.toThrow("Invalid handler file");
255
+ });
256
+
257
+ it("loads fallback from handler file", async () => {
258
+ const handlerPath = join(tmpDir, "with-fallback.ts");
259
+ await writeFile(
260
+ handlerPath,
261
+ `export const fallback = "Default reply.";
262
+ export default {
263
+ match: (req) => req.lastMessage.includes("hello"),
264
+ respond: () => "Hi!",
265
+ };`,
266
+ );
267
+
268
+ let capturedFallback: unknown;
269
+ await loadRulesFromPath(handlerPath, {
270
+ engine,
271
+ setFallback: (reply) => { capturedFallback = reply; },
272
+ });
273
+
274
+ expect(capturedFallback).toBe("Default reply.");
275
+ expect(engine.ruleCount).toBe(1);
276
+ });
277
+ });
278
+
279
+ describe("directory loading", () => {
280
+ it("loads all .json5 files from a directory", async () => {
281
+ await writeFile(
282
+ join(tmpDir, "a.json5"),
283
+ `[{ when: "aaa", reply: "A" }]`,
284
+ );
285
+ await writeFile(
286
+ join(tmpDir, "b.json5"),
287
+ `[{ when: "bbb", reply: "B" }]`,
288
+ );
289
+
290
+ await loadRulesFromPath(tmpDir, { engine });
291
+ expect(engine.ruleCount).toBe(2);
292
+ });
293
+
294
+ it("loads mixed .json5 and .ts files from a directory", async () => {
295
+ await writeFile(
296
+ join(tmpDir, "rules.json5"),
297
+ `[{ when: "static", reply: "From JSON5" }]`,
298
+ );
299
+ await writeFile(
300
+ join(tmpDir, "handler.ts"),
301
+ `export default {
302
+ match: (req) => req.lastMessage.includes("dynamic"),
303
+ respond: () => "From handler",
304
+ };`,
305
+ );
306
+
307
+ await loadRulesFromPath(tmpDir, { engine });
308
+ expect(engine.ruleCount).toBe(2);
309
+
310
+ expect(engine.match(makeReq({ lastMessage: "static" }))).toBeDefined();
311
+ expect(engine.match(makeReq({ lastMessage: "dynamic" }))).toBeDefined();
312
+ });
313
+ });
314
+ });