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.
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/test.yml +34 -0
- package/.markdownlint.jsonc +11 -0
- package/.node-version +1 -0
- package/.oxlintrc.json +35 -0
- package/ARCHITECTURE.md +125 -0
- package/LICENCE +21 -0
- package/README.md +448 -0
- package/package.json +55 -0
- package/src/cli-validators.ts +56 -0
- package/src/cli.ts +128 -0
- package/src/formats/anthropic/index.ts +14 -0
- package/src/formats/anthropic/parse.ts +48 -0
- package/src/formats/anthropic/schema.ts +133 -0
- package/src/formats/anthropic/serialize.ts +91 -0
- package/src/formats/openai/index.ts +14 -0
- package/src/formats/openai/parse.ts +34 -0
- package/src/formats/openai/schema.ts +147 -0
- package/src/formats/openai/serialize.ts +92 -0
- package/src/formats/parse-helpers.ts +79 -0
- package/src/formats/responses/index.ts +14 -0
- package/src/formats/responses/parse.ts +56 -0
- package/src/formats/responses/schema.ts +143 -0
- package/src/formats/responses/serialize.ts +129 -0
- package/src/formats/types.ts +17 -0
- package/src/history.ts +66 -0
- package/src/index.ts +44 -0
- package/src/loader.ts +213 -0
- package/src/logger.ts +58 -0
- package/src/mock-server.ts +237 -0
- package/src/route-handler.ts +113 -0
- package/src/rule-engine.ts +119 -0
- package/src/sse-writer.ts +35 -0
- package/src/types/index.ts +4 -0
- package/src/types/reply.ts +49 -0
- package/src/types/request.ts +45 -0
- package/src/types/rule.ts +74 -0
- package/src/types.ts +5 -0
- package/test/cli-validators.test.ts +131 -0
- package/test/formats/anthropic-schema.test.ts +192 -0
- package/test/formats/anthropic.test.ts +260 -0
- package/test/formats/openai-schema.test.ts +105 -0
- package/test/formats/openai.test.ts +243 -0
- package/test/formats/responses-schema.test.ts +114 -0
- package/test/formats/responses.test.ts +299 -0
- package/test/loader.test.ts +314 -0
- package/test/mock-server.test.ts +565 -0
- package/test/rule-engine.test.ts +213 -0
- package/tsconfig.json +26 -0
- package/tsconfig.test.json +11 -0
- package/vitest.config.ts +18 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { MockRequest } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** A recorded request with the rule that matched and when it happened. */
|
|
4
|
+
export interface RecordedRequest {
|
|
5
|
+
readonly request: MockRequest;
|
|
6
|
+
/** The rule that matched, or `undefined` if the fallback was used. */
|
|
7
|
+
readonly rule: string | undefined;
|
|
8
|
+
readonly timestamp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Records every request the server handles.
|
|
13
|
+
* Iterable and has fluent query methods for test assertions.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* expect(server.history.count()).toBe(3);
|
|
18
|
+
* expect(server.history.last()?.request.lastMessage).toBe("hello");
|
|
19
|
+
* const matched = server.history.where(r => r.rule !== undefined);
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export class RequestHistory {
|
|
23
|
+
private readonly entries: RecordedRequest[] = [];
|
|
24
|
+
|
|
25
|
+
record(request: MockRequest, rule: string | undefined): void {
|
|
26
|
+
this.entries.push({ request, rule, timestamp: Date.now() });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Number of recorded requests. */
|
|
30
|
+
count(): number {
|
|
31
|
+
return this.entries.length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** First recorded request, or `undefined` if empty. */
|
|
35
|
+
first(): RecordedRequest | undefined {
|
|
36
|
+
return this.entries[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Most recent recorded request, or `undefined` if empty. */
|
|
40
|
+
last(): RecordedRequest | undefined {
|
|
41
|
+
return this.entries.at(-1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Get the entry at a specific index. Supports negative indices. */
|
|
45
|
+
at(index: number): RecordedRequest | undefined {
|
|
46
|
+
return this.entries.at(index);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Filter entries by a predicate. */
|
|
50
|
+
where(predicate: (entry: RecordedRequest) => boolean): RecordedRequest[] {
|
|
51
|
+
return this.entries.filter(predicate);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** All entries as a readonly array. */
|
|
55
|
+
get all(): readonly RecordedRequest[] {
|
|
56
|
+
return this.entries;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clear(): void {
|
|
60
|
+
this.entries.length = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[Symbol.iterator](): Iterator<RecordedRequest> {
|
|
64
|
+
return this.entries[Symbol.iterator]();
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export { MockServer } from "./mock-server.js";
|
|
2
|
+
export type { MockServerOptions } from "./mock-server.js";
|
|
3
|
+
export type { LogLevel } from "./logger.js";
|
|
4
|
+
export { RequestHistory } from "./history.js";
|
|
5
|
+
export type { RecordedRequest } from "./history.js";
|
|
6
|
+
export type {
|
|
7
|
+
FormatName,
|
|
8
|
+
MockRequest,
|
|
9
|
+
Message,
|
|
10
|
+
ToolDef,
|
|
11
|
+
Reply,
|
|
12
|
+
ReplyObject,
|
|
13
|
+
ToolCall,
|
|
14
|
+
Resolver,
|
|
15
|
+
Match,
|
|
16
|
+
MatchObject,
|
|
17
|
+
ReplyOptions,
|
|
18
|
+
ErrorReply,
|
|
19
|
+
PendingRule,
|
|
20
|
+
RuleHandle,
|
|
21
|
+
RuleSummary,
|
|
22
|
+
SequenceEntry,
|
|
23
|
+
Handler,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
|
|
26
|
+
import { MockServer } from "./mock-server.js";
|
|
27
|
+
import type { MockServerOptions } from "./mock-server.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a server and start it in one go.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const server = await createMock({ port: 0, logLevel: "info" });
|
|
35
|
+
* server.when("hello").reply("Hi!");
|
|
36
|
+
* // run your tests
|
|
37
|
+
* await server.stop();
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export async function createMock(options: MockServerOptions = {}): Promise<MockServer> {
|
|
41
|
+
const server = new MockServer(options);
|
|
42
|
+
await server.start(options.port ?? 0);
|
|
43
|
+
return server;
|
|
44
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import JSON5 from "json5";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { Handler, Match, MatchObject, Reply } from "./types.js";
|
|
6
|
+
import type { RuleEngine } from "./rule-engine.js";
|
|
7
|
+
|
|
8
|
+
export interface LoadContext {
|
|
9
|
+
engine: RuleEngine;
|
|
10
|
+
setFallback?: (reply: Reply) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const json5MatchSchema = z.union([
|
|
14
|
+
z.string(),
|
|
15
|
+
z.object({
|
|
16
|
+
message: z.string().optional(),
|
|
17
|
+
model: z.string().optional(),
|
|
18
|
+
system: z.string().optional(),
|
|
19
|
+
format: z.enum(["openai", "anthropic", "responses"]).optional(),
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const json5ReplySchema = z.union([
|
|
24
|
+
z.string(),
|
|
25
|
+
z.object({
|
|
26
|
+
text: z.string().optional(),
|
|
27
|
+
reasoning: z.string().optional(),
|
|
28
|
+
tools: z.array(z.object({ name: z.string(), args: z.record(z.string(), z.unknown()) })).optional(),
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const json5ReplyRef = z.union([json5ReplySchema, z.string().startsWith("$")]);
|
|
33
|
+
|
|
34
|
+
const json5SequenceEntrySchema = z.union([
|
|
35
|
+
json5ReplyRef,
|
|
36
|
+
z.object({
|
|
37
|
+
reply: json5ReplyRef,
|
|
38
|
+
latency: z.int().nonnegative().optional(),
|
|
39
|
+
chunkSize: z.int().nonnegative().optional(),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const json5RuleSchema = z.union([
|
|
44
|
+
z.object({
|
|
45
|
+
when: json5MatchSchema,
|
|
46
|
+
reply: json5ReplyRef,
|
|
47
|
+
times: z.int().positive().optional(),
|
|
48
|
+
}),
|
|
49
|
+
z.object({
|
|
50
|
+
when: json5MatchSchema,
|
|
51
|
+
replies: z.array(json5SequenceEntrySchema).min(1),
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const json5FileSchema = z.union([
|
|
56
|
+
z.array(json5RuleSchema),
|
|
57
|
+
z.object({
|
|
58
|
+
templates: z.record(z.string(), json5ReplySchema).optional(),
|
|
59
|
+
fallback: json5ReplySchema.optional(),
|
|
60
|
+
rules: z.array(json5RuleSchema),
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function parseRegexString(s: string): RegExp | string {
|
|
65
|
+
const match = /^\/(.+)\/([dgimsuyv]*)$/.exec(s);
|
|
66
|
+
if (match) {
|
|
67
|
+
return new RegExp(match[1]!, match[2]);
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Json5ReplyRef = z.infer<typeof json5ReplyRef>;
|
|
73
|
+
type Templates = Record<string, z.infer<typeof json5ReplySchema>> | undefined;
|
|
74
|
+
|
|
75
|
+
function compileMatch(when: z.infer<typeof json5MatchSchema>): Match {
|
|
76
|
+
if (typeof when === "string") {
|
|
77
|
+
return parseRegexString(when);
|
|
78
|
+
}
|
|
79
|
+
const obj: MatchObject = {
|
|
80
|
+
...(when.message !== undefined && { message: parseRegexString(when.message) }),
|
|
81
|
+
...(when.model !== undefined && { model: parseRegexString(when.model) }),
|
|
82
|
+
...(when.system !== undefined && { system: parseRegexString(when.system) }),
|
|
83
|
+
...(when.format !== undefined && { format: when.format }),
|
|
84
|
+
};
|
|
85
|
+
return obj;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveReplyRef(
|
|
89
|
+
ref: Json5ReplyRef,
|
|
90
|
+
templates: Templates,
|
|
91
|
+
filePath: string,
|
|
92
|
+
): z.infer<typeof json5ReplySchema> {
|
|
93
|
+
if (typeof ref === "string" && ref.startsWith("$")) {
|
|
94
|
+
const name = ref.slice(1);
|
|
95
|
+
const resolved = templates?.[name];
|
|
96
|
+
if (!resolved) throw new Error(`Unknown template "${name}" in ${filePath}`);
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
return ref;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function addSequenceRule(
|
|
103
|
+
engine: RuleEngine,
|
|
104
|
+
match: Match,
|
|
105
|
+
entries: z.infer<typeof json5SequenceEntrySchema>[],
|
|
106
|
+
templates: Templates,
|
|
107
|
+
filePath: string,
|
|
108
|
+
): void {
|
|
109
|
+
let index = 0;
|
|
110
|
+
const resolved = entries.map((entry) => {
|
|
111
|
+
if (typeof entry === "string" || !("reply" in entry)) {
|
|
112
|
+
return { reply: resolveReplyRef(entry, templates, filePath) };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
reply: resolveReplyRef(entry.reply, templates, filePath),
|
|
116
|
+
options: {
|
|
117
|
+
...(entry.latency !== undefined && { latency: entry.latency }),
|
|
118
|
+
...(entry.chunkSize !== undefined && { chunkSize: entry.chunkSize }),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
const lastStep = resolved[resolved.length - 1]!;
|
|
123
|
+
const rule = engine.add(match, () => {
|
|
124
|
+
const step = resolved[index++] ?? lastStep;
|
|
125
|
+
rule.options = step.options ?? {};
|
|
126
|
+
return step.reply;
|
|
127
|
+
});
|
|
128
|
+
rule.remaining = resolved.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadJson5File(filePath: string, ctx: LoadContext): Promise<void> {
|
|
132
|
+
const content = await readFile(filePath, "utf-8");
|
|
133
|
+
const parsed = json5FileSchema.parse(JSON5.parse(content));
|
|
134
|
+
|
|
135
|
+
const rules = Array.isArray(parsed) ? parsed : parsed.rules;
|
|
136
|
+
const templates = Array.isArray(parsed) ? undefined : parsed.templates;
|
|
137
|
+
|
|
138
|
+
if (!Array.isArray(parsed) && parsed.fallback !== undefined && ctx.setFallback) {
|
|
139
|
+
ctx.setFallback(parsed.fallback);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const r of rules) {
|
|
143
|
+
const match = compileMatch(r.when);
|
|
144
|
+
if ("replies" in r) {
|
|
145
|
+
addSequenceRule(ctx.engine, match, r.replies, templates, filePath);
|
|
146
|
+
} else {
|
|
147
|
+
const reply = resolveReplyRef(r.reply, templates, filePath);
|
|
148
|
+
const rule = ctx.engine.add(match, reply);
|
|
149
|
+
if (r.times !== undefined) {
|
|
150
|
+
rule.remaining = r.times;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const handlerSchema = z.custom<Handler>((val): val is Handler => {
|
|
157
|
+
if (typeof val !== "object" || val === null) return false;
|
|
158
|
+
const obj = val as Record<string, unknown>;
|
|
159
|
+
return typeof obj["match"] === "function" && typeof obj["respond"] === "function";
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const handlerExportSchema = z.object({
|
|
163
|
+
default: z.union([handlerSchema, z.array(handlerSchema)]),
|
|
164
|
+
fallback: json5ReplySchema.optional(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
async function loadHandlerFile(filePath: string, ctx: LoadContext): Promise<void> {
|
|
168
|
+
const mod = await import(filePath);
|
|
169
|
+
const parsed = handlerExportSchema.safeParse(mod);
|
|
170
|
+
if (!parsed.success) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Invalid handler file ${filePath}. Expected default export with { match: Function, respond: Function }.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const handlers = Array.isArray(parsed.data.default) ? parsed.data.default : [parsed.data.default];
|
|
176
|
+
|
|
177
|
+
if (parsed.data.fallback !== undefined && ctx.setFallback) {
|
|
178
|
+
ctx.setFallback(parsed.data.fallback);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const handler of handlers) {
|
|
182
|
+
ctx.engine.addHandler(handler.match, handler.respond, `(handler: ${filePath})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type FileLoader = (filePath: string, ctx: LoadContext) => Promise<void>;
|
|
187
|
+
|
|
188
|
+
const loaderByExtension: ReadonlyMap<string, FileLoader> = new Map([
|
|
189
|
+
[".json5", loadJson5File],
|
|
190
|
+
[".json", loadJson5File],
|
|
191
|
+
[".ts", loadHandlerFile],
|
|
192
|
+
[".js", loadHandlerFile],
|
|
193
|
+
[".mjs", loadHandlerFile],
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
export async function loadRulesFromPath(pathOrDir: string, ctx: LoadContext): Promise<void> {
|
|
197
|
+
const info = await stat(pathOrDir);
|
|
198
|
+
|
|
199
|
+
if (info.isFile()) {
|
|
200
|
+
const loader = loaderByExtension.get(extname(pathOrDir));
|
|
201
|
+
if (loader) await loader(pathOrDir, ctx);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!info.isDirectory()) return;
|
|
206
|
+
|
|
207
|
+
const entries = (await readdir(pathOrDir)).toSorted();
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const fullPath = join(pathOrDir, entry);
|
|
210
|
+
const entryStat = await stat(fullPath);
|
|
211
|
+
if (entryStat.isFile()) await loadRulesFromPath(fullPath, ctx);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
export const LEVEL_PRIORITY = {
|
|
4
|
+
none: 0,
|
|
5
|
+
error: 1,
|
|
6
|
+
warning: 2,
|
|
7
|
+
info: 3,
|
|
8
|
+
debug: 4,
|
|
9
|
+
all: 5,
|
|
10
|
+
} as const satisfies Record<string, number>;
|
|
11
|
+
|
|
12
|
+
/** Log verbosity, from `"none"` (silent) through to `"all"` (everything). */
|
|
13
|
+
export type LogLevel = keyof typeof LEVEL_PRIORITY;
|
|
14
|
+
|
|
15
|
+
const LEVEL_STYLE = {
|
|
16
|
+
error: { label: pc.red(pc.bold("ERROR")), symbol: pc.red("✗") },
|
|
17
|
+
warn: { label: pc.yellow(pc.bold("WARN")), symbol: pc.yellow("!") },
|
|
18
|
+
info: { label: pc.cyan("INFO"), symbol: pc.cyan("●") },
|
|
19
|
+
debug: { label: pc.dim("DEBUG"), symbol: pc.dim("·") },
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export class Logger {
|
|
23
|
+
readonly level: LogLevel;
|
|
24
|
+
private threshold: number;
|
|
25
|
+
|
|
26
|
+
constructor(level: LogLevel = "info") {
|
|
27
|
+
this.level = level;
|
|
28
|
+
this.threshold = LEVEL_PRIORITY[level];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
error(msg: string, ...args: unknown[]): void {
|
|
32
|
+
if (this.threshold >= LEVEL_PRIORITY.error) {
|
|
33
|
+
const { label, symbol } = LEVEL_STYLE.error;
|
|
34
|
+
console.error(`${pc.dim(new Date().toISOString())} ${symbol} ${label} ${msg}`, ...args);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
warn(msg: string, ...args: unknown[]): void {
|
|
39
|
+
if (this.threshold >= LEVEL_PRIORITY.warning) {
|
|
40
|
+
const { label, symbol } = LEVEL_STYLE.warn;
|
|
41
|
+
console.warn(`${pc.dim(new Date().toISOString())} ${symbol} ${label} ${msg}`, ...args);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
info(msg: string, ...args: unknown[]): void {
|
|
46
|
+
if (this.threshold >= LEVEL_PRIORITY.info) {
|
|
47
|
+
const { label, symbol } = LEVEL_STYLE.info;
|
|
48
|
+
console.log(`${pc.dim(new Date().toISOString())} ${symbol} ${label} ${msg}`, ...args);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
debug(msg: string, ...args: unknown[]): void {
|
|
53
|
+
if (this.threshold >= LEVEL_PRIORITY.debug) {
|
|
54
|
+
const { label, symbol } = LEVEL_STYLE.debug;
|
|
55
|
+
console.log(`${pc.dim(new Date().toISOString())} ${symbol} ${label} ${pc.dim(msg)}`, ...args);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
import type {
|
|
4
|
+
Match, PendingRule, Reply, ReplyOptions, Resolver, Rule, RuleHandle, RuleSummary, SequenceEntry,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
import { RuleEngine } from "./rule-engine.js";
|
|
7
|
+
import { RequestHistory } from "./history.js";
|
|
8
|
+
import { openaiFormat } from "./formats/openai/index.js";
|
|
9
|
+
import { anthropicFormat } from "./formats/anthropic/index.js";
|
|
10
|
+
import { responsesFormat } from "./formats/responses/index.js";
|
|
11
|
+
import type { Format } from "./formats/types.js";
|
|
12
|
+
import { Logger } from "./logger.js";
|
|
13
|
+
import type { LogLevel } from "./logger.js";
|
|
14
|
+
import { createRouteHandler } from "./route-handler.js";
|
|
15
|
+
|
|
16
|
+
const formats: readonly Format[] = [openaiFormat, anthropicFormat, responsesFormat];
|
|
17
|
+
|
|
18
|
+
export interface MockServerOptions {
|
|
19
|
+
readonly port?: number;
|
|
20
|
+
/** Defaults to `"127.0.0.1"`. Set to `"0.0.0.0"` to listen on all interfaces. */
|
|
21
|
+
readonly host?: string;
|
|
22
|
+
/** Defaults to `"none"` (silent). */
|
|
23
|
+
readonly logLevel?: LogLevel;
|
|
24
|
+
/** Default ms delay between SSE chunks. Individual rules can override this. */
|
|
25
|
+
readonly defaultLatency?: number;
|
|
26
|
+
/** Default characters per SSE text chunk. Individual rules can override this. */
|
|
27
|
+
readonly defaultChunkSize?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mock LLM server that handles OpenAI, Anthropic, and Responses API formats.
|
|
32
|
+
* Register rules with `when()`, point your SDK at `url`, and go.
|
|
33
|
+
*
|
|
34
|
+
* Supports `await using` for automatic cleanup.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const server = new MockServer({ logLevel: "info" });
|
|
39
|
+
* server.when("hello").reply("Hi there!");
|
|
40
|
+
* await server.start();
|
|
41
|
+
* // Point your client at server.url
|
|
42
|
+
* await server.stop();
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class MockServer {
|
|
46
|
+
private readonly app: FastifyInstance;
|
|
47
|
+
private readonly engine = new RuleEngine();
|
|
48
|
+
private readonly history_ = new RequestHistory();
|
|
49
|
+
private readonly logger: Logger;
|
|
50
|
+
private readonly host: string;
|
|
51
|
+
private readonly defaultOptions: ReplyOptions;
|
|
52
|
+
private fallbackReply: Reply = "Mock server: no matching rule.";
|
|
53
|
+
private listening = false;
|
|
54
|
+
|
|
55
|
+
constructor(options: MockServerOptions = {}) {
|
|
56
|
+
this.host = options.host ?? "127.0.0.1";
|
|
57
|
+
this.logger = new Logger(options.logLevel ?? "none");
|
|
58
|
+
this.defaultOptions = {
|
|
59
|
+
...(options.defaultLatency !== undefined && { latency: options.defaultLatency }),
|
|
60
|
+
...(options.defaultChunkSize !== undefined && { chunkSize: options.defaultChunkSize }),
|
|
61
|
+
};
|
|
62
|
+
this.app = Fastify({ logger: false });
|
|
63
|
+
|
|
64
|
+
const deps = {
|
|
65
|
+
engine: this.engine,
|
|
66
|
+
history: this.history_,
|
|
67
|
+
logger: this.logger,
|
|
68
|
+
defaultOptions: this.defaultOptions,
|
|
69
|
+
getFallback: () => this.fallbackReply,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const format of formats) {
|
|
73
|
+
this.app.post(format.route, createRouteHandler(format, deps));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register a matching rule. Call `.reply()` on the result to set the response.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* server.when("hello").reply("Hi!");
|
|
83
|
+
* server.when(/explain (\w+)/i).reply((req) => `Let me explain ${req.lastMessage}`);
|
|
84
|
+
* server.when({ model: /claude/ }).reply("I'm Claude.");
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
when(match: Match): PendingRule {
|
|
88
|
+
const engine = this.engine;
|
|
89
|
+
|
|
90
|
+
const makeHandle = (rule: Rule): RuleHandle => ({
|
|
91
|
+
times(n: number): RuleHandle {
|
|
92
|
+
rule.remaining = n;
|
|
93
|
+
return this;
|
|
94
|
+
},
|
|
95
|
+
first(): RuleHandle {
|
|
96
|
+
engine.moveToFront(rule);
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
reply(response: Resolver, options?: ReplyOptions): RuleHandle {
|
|
103
|
+
return makeHandle(engine.add(match, response, options));
|
|
104
|
+
},
|
|
105
|
+
replySequence(entries: readonly SequenceEntry[]): RuleHandle {
|
|
106
|
+
if (entries.length === 0) throw new Error("replySequence requires at least one entry.");
|
|
107
|
+
let index = 0;
|
|
108
|
+
const last = entries[entries.length - 1]!;
|
|
109
|
+
const rule = engine.add(match, () => {
|
|
110
|
+
const entry = entries[index++] ?? last;
|
|
111
|
+
if (typeof entry === "string" || !("reply" in entry)) {
|
|
112
|
+
rule.options = {};
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
rule.options = entry.options ?? {};
|
|
116
|
+
return entry.reply;
|
|
117
|
+
});
|
|
118
|
+
rule.remaining = entries.length;
|
|
119
|
+
return makeHandle(rule);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register a rule that matches when the request includes a tool with this name.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* server.whenTool("get_weather").reply({
|
|
130
|
+
* tools: [{ name: "get_weather", args: { location: "London" } }],
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
whenTool(toolName: string): PendingRule {
|
|
135
|
+
return this.when({ toolName });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Register a rule that matches when the last message is a tool result with this call ID.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* server.whenToolResult("call_abc").reply("Got your result, cheers!");
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
whenToolResult(toolCallId: string): PendingRule {
|
|
147
|
+
return this.when({ toolCallId });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Queue a one-shot error for the very next request, regardless of content.
|
|
152
|
+
* Fires once then removes itself.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* server.nextError(429, "Rate limited");
|
|
157
|
+
* // next request gets a 429, after that normal matching resumes
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
nextError(status: number, message: string, type?: string): RuleHandle {
|
|
161
|
+
return this.when(() => true)
|
|
162
|
+
.reply({ error: { status, message, type } })
|
|
163
|
+
.times(1)
|
|
164
|
+
.first();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Set the reply used when no rule matches. Defaults to a generic message. */
|
|
168
|
+
fallback(reply: Reply): void {
|
|
169
|
+
this.fallbackReply = reply;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Load rules from a `.json5` file, a `.ts`/`.js` handler file, or a directory containing them. */
|
|
173
|
+
async load(pathOrDir: string): Promise<void> {
|
|
174
|
+
const before = this.engine.ruleCount;
|
|
175
|
+
const { loadRulesFromPath } = await import("./loader.js");
|
|
176
|
+
await loadRulesFromPath(pathOrDir, {
|
|
177
|
+
engine: this.engine,
|
|
178
|
+
setFallback: (reply) => { this.fallbackReply = reply; },
|
|
179
|
+
});
|
|
180
|
+
const loaded = this.engine.ruleCount - before;
|
|
181
|
+
this.logger.info(`Loaded ${loaded} rule${loaded !== 1 ? "s" : ""} from ${pathOrDir}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every request the server has handled. */
|
|
185
|
+
get history(): RequestHistory {
|
|
186
|
+
return this.history_;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Returns `true` when all rules with a `.times()` limit have been consumed. */
|
|
190
|
+
isDone(): boolean {
|
|
191
|
+
return this.engine.isDone();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Clear all rules, request history, and reset the fallback to its default. */
|
|
195
|
+
reset(): void {
|
|
196
|
+
this.engine.clear();
|
|
197
|
+
this.history_.clear();
|
|
198
|
+
this.fallbackReply = "Mock server: no matching rule.";
|
|
199
|
+
this.logger.info("Server reset: rules and history cleared");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The base URL the server is listening on, e.g. `http://127.0.0.1:12345`. Throws if the server hasn't started. */
|
|
203
|
+
get url(): string {
|
|
204
|
+
if (!this.listening) throw new Error("Server is not running. Call start() first.");
|
|
205
|
+
const addr = this.app.server.address();
|
|
206
|
+
const port = addr !== null && typeof addr === "object" ? addr.port : 0;
|
|
207
|
+
return `http://${this.host}:${port}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get ruleCount(): number {
|
|
211
|
+
return this.engine.ruleCount;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A snapshot of all registered rules with their descriptions and remaining match counts. */
|
|
215
|
+
get rules(): readonly RuleSummary[] {
|
|
216
|
+
return this.engine.describe();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Start listening. Pass `0` (the default) for a random port. */
|
|
220
|
+
async start(port = 0): Promise<void> {
|
|
221
|
+
if (this.listening) throw new Error("Server is already running.");
|
|
222
|
+
await this.app.listen({ port, host: this.host });
|
|
223
|
+
this.listening = true;
|
|
224
|
+
this.logger.info(`Listening on ${this.url}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async stop(): Promise<void> {
|
|
228
|
+
if (!this.listening) return;
|
|
229
|
+
await this.app.close();
|
|
230
|
+
this.listening = false;
|
|
231
|
+
this.logger.info("Server stopped");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
235
|
+
await this.stop();
|
|
236
|
+
}
|
|
237
|
+
}
|