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,213 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { RuleEngine } from "../src/rule-engine.js";
3
+ import type { MockRequest } from "../src/types.js";
4
+
5
+ function makeReq(overrides: Partial<MockRequest> = {}): MockRequest {
6
+ return {
7
+ format: "openai",
8
+ model: "gpt-5.4",
9
+ streaming: true,
10
+ messages: [{ role: "user", content: "hello" }],
11
+ lastMessage: "hello",
12
+ systemMessage: "",
13
+ toolNames: [],
14
+ lastToolCallId: undefined,
15
+ raw: {},
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe("RuleEngine", () => {
21
+ let engine: RuleEngine;
22
+
23
+ beforeEach(() => {
24
+ engine = new RuleEngine();
25
+ });
26
+
27
+ it("matches a string (substring, case-insensitive)", () => {
28
+ engine.add("hello", "Hi!");
29
+ const rule = engine.match(makeReq({ lastMessage: "say Hello world" }));
30
+ expect(rule).toBeDefined();
31
+ expect(rule!.description).toBe('"hello"');
32
+ });
33
+
34
+ it("matches a regex", () => {
35
+ engine.add(/explain (\w+)/i, "Here is an explanation.");
36
+ const rule = engine.match(makeReq({ lastMessage: "Can you explain recursion?" }));
37
+ expect(rule).toBeDefined();
38
+ });
39
+
40
+ it("matches a predicate function", () => {
41
+ engine.add((req) => req.messages.length > 2, "Long conversation");
42
+ const rule = engine.match(
43
+ makeReq({
44
+ messages: [
45
+ { role: "system", content: "You are helpful" },
46
+ { role: "user", content: "hi" },
47
+ { role: "assistant", content: "hello" },
48
+ ],
49
+ }),
50
+ );
51
+ expect(rule).toBeDefined();
52
+ });
53
+
54
+ it("matches a MatchObject with model", () => {
55
+ engine.add({ model: "gpt-5.4" }, "I'm GPT-5.4");
56
+ expect(engine.match(makeReq({ model: "gpt-5.4" }))).toBeDefined();
57
+ expect(engine.match(makeReq({ model: "claude-3" }))).toBeUndefined();
58
+ });
59
+
60
+ it("matches a MatchObject with message + model", () => {
61
+ engine.add({ model: "gpt-5.4", message: "hello" }, "Hi from GPT-5.4");
62
+ expect(engine.match(makeReq({ model: "gpt-5.4", lastMessage: "hello" }))).toBeDefined();
63
+ expect(engine.match(makeReq({ model: "gpt-5.4", lastMessage: "bye" }))).toBeUndefined();
64
+ expect(engine.match(makeReq({ model: "claude", lastMessage: "hello" }))).toBeUndefined();
65
+ });
66
+
67
+ it("matches a MatchObject with system", () => {
68
+ engine.add({ system: /pirate/i }, "Arrr!");
69
+ expect(engine.match(makeReq({ systemMessage: "You are a pirate" }))).toBeDefined();
70
+ expect(engine.match(makeReq({ systemMessage: "You are helpful" }))).toBeUndefined();
71
+ });
72
+
73
+ it("matches a MatchObject with format", () => {
74
+ engine.add({ format: "anthropic" }, "Anthropic only");
75
+ expect(engine.match(makeReq({ format: "anthropic" }))).toBeDefined();
76
+ expect(engine.match(makeReq({ format: "openai" }))).toBeUndefined();
77
+ });
78
+
79
+ it("returns first match (first-match-wins)", () => {
80
+ engine.add("hello", "First");
81
+ engine.add("hello", "Second");
82
+ const rule = engine.match(makeReq());
83
+ expect(rule!.resolve).toBe("First");
84
+ });
85
+
86
+ it("returns undefined when no rules match", () => {
87
+ engine.add("goodbye", "Bye!");
88
+ expect(engine.match(makeReq({ lastMessage: "hello" }))).toBeUndefined();
89
+ });
90
+
91
+ describe("times()", () => {
92
+ it("decrements and removes rule after N matches", () => {
93
+ const rule = engine.add("hello", "Once");
94
+ rule.remaining = 1;
95
+
96
+ expect(engine.match(makeReq())).toBeDefined();
97
+ expect(engine.match(makeReq())).toBeUndefined();
98
+ });
99
+
100
+ it("allows multiple matches with times > 1", () => {
101
+ const rule = engine.add("hello", "Twice");
102
+ rule.remaining = 2;
103
+
104
+ expect(engine.match(makeReq())).toBeDefined();
105
+ expect(engine.match(makeReq())).toBeDefined();
106
+ expect(engine.match(makeReq())).toBeUndefined();
107
+ });
108
+
109
+ it("isDone() returns true when all limited rules are consumed", () => {
110
+ const rule = engine.add("hello", "Once");
111
+ rule.remaining = 1;
112
+ engine.add("world", "Unlimited"); // no times limit
113
+
114
+ expect(engine.isDone()).toBe(false);
115
+ engine.match(makeReq());
116
+ expect(engine.isDone()).toBe(true);
117
+ });
118
+ });
119
+
120
+ it("clear() removes all rules", () => {
121
+ engine.add("hello", "Hi");
122
+ engine.add("bye", "Bye");
123
+ engine.clear();
124
+ expect(engine.ruleCount).toBe(0);
125
+ });
126
+
127
+ it("addHandler adds a handler-style rule", () => {
128
+ engine.addHandler(
129
+ (req) => req.lastMessage.includes("test"),
130
+ "Handler reply",
131
+ );
132
+ expect(engine.match(makeReq({ lastMessage: "this is a test" }))).toBeDefined();
133
+ });
134
+
135
+ describe("toolName matching", () => {
136
+ it("matches when toolNames includes the specified tool", () => {
137
+ engine.add({ toolName: "get_weather" }, "Weather tool present");
138
+ expect(engine.match(makeReq({ toolNames: ["get_weather", "search"] }))).toBeDefined();
139
+ expect(engine.match(makeReq({ toolNames: ["search"] }))).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ describe("toolCallId matching", () => {
144
+ it("matches when lastToolCallId equals the specified id", () => {
145
+ engine.add({ toolCallId: "call_abc" }, "Tool result");
146
+ expect(engine.match(makeReq({ lastToolCallId: "call_abc" }))).toBeDefined();
147
+ expect(engine.match(makeReq({ lastToolCallId: "call_xyz" }))).toBeUndefined();
148
+ expect(engine.match(makeReq())).toBeUndefined();
149
+ });
150
+ });
151
+
152
+ describe("moveToFront()", () => {
153
+ it("moves an existing rule to the front", () => {
154
+ engine.add("hello", "First");
155
+ const rule = engine.add("hello", "Second");
156
+ engine.moveToFront(rule);
157
+ const matched = engine.match(makeReq());
158
+ expect(matched!.resolve).toBe("Second");
159
+ });
160
+ });
161
+
162
+ describe("MatchObject with predicate", () => {
163
+ it("combines structured fields with a predicate function", () => {
164
+ engine.add(
165
+ { model: "gpt-5.4", predicate: (req) => req.messages.length > 2 },
166
+ "Complex match",
167
+ );
168
+ // Model matches but predicate fails (only 1 message)
169
+ expect(engine.match(makeReq({ model: "gpt-5.4" }))).toBeUndefined();
170
+ // Both model and predicate pass
171
+ expect(engine.match(makeReq({
172
+ model: "gpt-5.4",
173
+ messages: [
174
+ { role: "system", content: "sys" },
175
+ { role: "user", content: "a" },
176
+ { role: "assistant", content: "b" },
177
+ ],
178
+ }))).toBeDefined();
179
+ });
180
+
181
+ it("predicate runs after other fields (short-circuits)", () => {
182
+ let called = false;
183
+ engine.add(
184
+ { model: "claude", predicate: () => { called = true; return true; } },
185
+ "Never reached",
186
+ );
187
+ engine.match(makeReq({ model: "gpt-5.4" }));
188
+ expect(called).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("global/sticky regex safety", () => {
193
+ it("strips the g flag so test() is not stateful", () => {
194
+ engine.add(/hello/g, "Hi!");
195
+ expect(engine.match(makeReq())).toBeDefined();
196
+ expect(engine.match(makeReq())).toBeDefined();
197
+ expect(engine.match(makeReq())).toBeDefined();
198
+ });
199
+
200
+ it("strips the y flag so test() is not stateful", () => {
201
+ engine.add(/hello/y, "Hi!");
202
+ expect(engine.match(makeReq())).toBeDefined();
203
+ expect(engine.match(makeReq())).toBeDefined();
204
+ });
205
+
206
+ it("strips g flag from regex inside a MatchObject", () => {
207
+ engine.add({ message: /hello/gi }, "Hi!");
208
+ expect(engine.match(makeReq())).toBeDefined();
209
+ expect(engine.match(makeReq())).toBeDefined();
210
+ expect(engine.match(makeReq())).toBeDefined();
211
+ });
212
+ });
213
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2024"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "verbatimModuleSyntax": true,
17
+ "resolveJsonModule": true,
18
+ "isolatedModules": true,
19
+ "noUncheckedIndexedAccess": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "exactOptionalPropertyTypes": true
23
+ },
24
+ "include": ["src/**/*"],
25
+ "exclude": ["node_modules", "dist", "test"]
26
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "declaration": false,
6
+ "declarationMap": false,
7
+ "sourceMap": false,
8
+ "noUnusedLocals": false
9
+ },
10
+ "include": ["src/**/*", "test/**/*"]
11
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ testTimeout: 10_000,
7
+ coverage: {
8
+ provider: "v8",
9
+ include: ["src/**/*.ts"],
10
+ exclude: [
11
+ "src/cli.ts",
12
+ "src/types.ts",
13
+ "src/types/**",
14
+ "src/formats/types.ts",
15
+ ],
16
+ },
17
+ },
18
+ });