llm-mock-server 1.0.4 → 1.0.6
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/.desloppify/query.json +1162 -62
- package/.desloppify/review_packet_blind.json +18 -18
- package/.desloppify/review_packets/holistic_packet_20260315_185401.json +1407 -0
- package/.desloppify/review_packets/holistic_packet_20260315_185613.json +1407 -0
- package/.desloppify/state-typescript.json +2530 -645
- package/.desloppify/state-typescript.json.bak +2494 -582
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-1.log +384 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-10.log +484 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-2.log +408 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-3.log +416 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-4.log +360 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-5.log +360 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-6.log +364 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-7.log +428 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-8.log +388 -0
- package/.desloppify/subagents/runs/20260315_185401/logs/batch-9.log +500 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-1.md +83 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-10.md +108 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-2.md +89 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-3.md +91 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-4.md +77 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-5.md +77 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-6.md +78 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-7.md +94 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-8.md +84 -0
- package/.desloppify/subagents/runs/20260315_185401/prompts/batch-9.md +112 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-1.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-10.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-2.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-3.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-4.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-5.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-6.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-7.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-8.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/results/batch-9.raw.txt +0 -0
- package/.desloppify/subagents/runs/20260315_185401/run.log +36 -0
- package/.desloppify/subagents/runs/20260315_185401/run_summary.json +156 -0
- package/.desloppify/subagents/runs/20260315_185613/holistic_findings_merged.json +741 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-1.log +579 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-10.log +1537 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-2.log +829 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-3.log +927 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-4.log +429 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-5.log +276 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-6.log +450 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-7.log +730 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-8.log +698 -0
- package/.desloppify/subagents/runs/20260315_185613/logs/batch-9.log +938 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-1.md +83 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-10.md +108 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-2.md +89 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-3.md +91 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-4.md +77 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-5.md +77 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-6.md +78 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-7.md +94 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-8.md +84 -0
- package/.desloppify/subagents/runs/20260315_185613/prompts/batch-9.md +112 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-1.raw.txt +78 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-10.raw.txt +242 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-2.raw.txt +102 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-3.raw.txt +94 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-4.raw.txt +86 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-5.raw.txt +1 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-6.raw.txt +87 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-7.raw.txt +1 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-8.raw.txt +107 -0
- package/.desloppify/subagents/runs/20260315_185613/results/batch-9.raw.txt +67 -0
- package/.desloppify/subagents/runs/20260315_185613/run.log +96 -0
- package/.desloppify/subagents/runs/20260315_185613/run_summary.json +156 -0
- package/.github/workflows/docs.yml +46 -0
- package/.github/workflows/test.yml +3 -0
- package/README.md +8 -4
- package/docs/ARCHITECTURE.md +11 -11
- package/package.json +18 -11
- package/scorecard.png +0 -0
- package/src/{cli.ts → cli/cli.ts} +6 -11
- package/src/{cli-validators.ts → cli/validators.ts} +10 -9
- package/src/formats/anthropic/index.ts +2 -2
- package/src/formats/anthropic/parse.ts +5 -2
- package/src/formats/anthropic/serialize.ts +3 -3
- package/src/formats/openai/{index.ts → chat-completions/index.ts} +3 -3
- package/src/formats/openai/{parse.ts → chat-completions/parse.ts} +5 -2
- package/src/formats/openai/{serialize.ts → chat-completions/serialize.ts} +3 -3
- package/src/formats/{responses → openai/responses}/index.ts +2 -2
- package/src/formats/{responses → openai/responses}/parse.ts +5 -2
- package/src/formats/{responses → openai/responses}/serialize.ts +3 -3
- package/src/formats/request-helpers.ts +6 -1
- package/src/formats/serialize-helpers.ts +9 -4
- package/src/formats/types.ts +2 -6
- package/src/history.ts +6 -2
- package/src/loader.ts +2 -1
- package/src/mock-server.ts +55 -106
- package/src/route-handler.ts +7 -11
- package/src/rule-builder.ts +73 -0
- package/src/rule-engine.ts +3 -10
- package/src/sse-writer.ts +1 -1
- package/src/types/reply.ts +51 -8
- package/src/types/request.ts +21 -6
- package/src/types/rule.ts +65 -7
- package/test/cli-validators.test.ts +13 -5
- package/test/formats/openai.test.ts +40 -28
- package/test/formats/responses.test.ts +2 -2
- package/test/history.test.ts +1 -1
- package/test/loader.test.ts +3 -3
- package/test/logger.test.ts +2 -2
- package/test/mock-server.test.ts +1 -1
- package/test/rule-engine.test.ts +1 -1
- package/tsconfig.json +2 -4
- package/typedoc.json +9 -0
- /package/src/formats/openai/{schema.ts → chat-completions/schema.ts} +0 -0
- /package/src/formats/{responses → openai/responses}/schema.ts +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ReplyObject, ReplyOptions } from "
|
|
2
|
-
import type { SSEChunk } from "
|
|
1
|
+
import type { ReplyObject, ReplyOptions } from "#/types/reply.js";
|
|
2
|
+
import type { SSEChunk } from "#/formats/types.js";
|
|
3
3
|
import {
|
|
4
4
|
splitText,
|
|
5
5
|
genId,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
shouldEmitText,
|
|
8
8
|
finishReason,
|
|
9
9
|
DEFAULT_USAGE,
|
|
10
|
-
} from "
|
|
10
|
+
} from "#/formats/serialize-helpers.js";
|
|
11
11
|
|
|
12
12
|
function buildUsage(usage: { input: number; output: number }) {
|
|
13
13
|
return { input_tokens: usage.input, output_tokens: usage.output };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { Format } from "
|
|
2
|
-
import { isStreaming } from "
|
|
1
|
+
import type { Format } from "#/formats/types.js";
|
|
2
|
+
import { isStreaming } from "#/formats/request-helpers.js";
|
|
3
3
|
import { parseRequest } from "./parse.js";
|
|
4
4
|
import { serialize, serializeComplete, serializeError } from "./serialize.js";
|
|
5
5
|
|
|
6
|
-
export const
|
|
6
|
+
export const chatCompletionsFormat: Format = {
|
|
7
7
|
name: "openai",
|
|
8
8
|
route: "/v1/chat/completions",
|
|
9
9
|
parseRequest,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { MockRequest, Message, ToolDef } from "
|
|
2
|
-
import {
|
|
1
|
+
import type { MockRequest, Message, ToolDef } from "#/types/request.js";
|
|
2
|
+
import {
|
|
3
|
+
buildMockRequest,
|
|
4
|
+
type RequestMeta,
|
|
5
|
+
} from "#/formats/request-helpers.js";
|
|
3
6
|
import { OpenAIRequestSchema, type OpenAIRequest } from "./schema.js";
|
|
4
7
|
|
|
5
8
|
function extractContent(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ReplyObject, ReplyOptions } from "
|
|
2
|
-
import type { SSEChunk } from "
|
|
1
|
+
import type { ReplyObject, ReplyOptions } from "#/types/reply.js";
|
|
2
|
+
import type { SSEChunk } from "#/formats/types.js";
|
|
3
3
|
import {
|
|
4
4
|
splitText,
|
|
5
5
|
genId,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
finishReason,
|
|
8
8
|
MS_PER_SECOND,
|
|
9
9
|
DEFAULT_USAGE,
|
|
10
|
-
} from "
|
|
10
|
+
} from "#/formats/serialize-helpers.js";
|
|
11
11
|
|
|
12
12
|
function buildUsage(usage: { input: number; output: number }) {
|
|
13
13
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Format } from "
|
|
2
|
-
import { isStreaming } from "
|
|
1
|
+
import type { Format } from "#/formats/types.js";
|
|
2
|
+
import { isStreaming } from "#/formats/request-helpers.js";
|
|
3
3
|
import { parseRequest } from "./parse.js";
|
|
4
4
|
import { serialize, serializeComplete, serializeError } from "./serialize.js";
|
|
5
5
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { MockRequest, Message, ToolDef } from "
|
|
2
|
-
import {
|
|
1
|
+
import type { MockRequest, Message, ToolDef } from "#/types/request.js";
|
|
2
|
+
import {
|
|
3
|
+
buildMockRequest,
|
|
4
|
+
type RequestMeta,
|
|
5
|
+
} from "#/formats/request-helpers.js";
|
|
3
6
|
import {
|
|
4
7
|
ResponsesRequestSchema,
|
|
5
8
|
FunctionToolSchema,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ReplyObject, ReplyOptions, ToolCall } from "
|
|
2
|
-
import type { SSEChunk } from "
|
|
1
|
+
import type { ReplyObject, ReplyOptions, ToolCall } from "#/types/reply.js";
|
|
2
|
+
import type { SSEChunk } from "#/formats/types.js";
|
|
3
3
|
import {
|
|
4
4
|
splitText,
|
|
5
5
|
genId,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
shouldEmitText,
|
|
8
8
|
MS_PER_SECOND,
|
|
9
9
|
DEFAULT_USAGE,
|
|
10
|
-
} from "
|
|
10
|
+
} from "#/formats/serialize-helpers.js";
|
|
11
11
|
|
|
12
12
|
function buildUsage(usage: { input: number; output: number }) {
|
|
13
13
|
return {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
FormatName,
|
|
3
|
+
Message,
|
|
4
|
+
MockRequest,
|
|
5
|
+
ToolDef,
|
|
6
|
+
} from "#/types/request.js";
|
|
2
7
|
|
|
3
8
|
function asRecord(body: unknown): Record<string, unknown> {
|
|
4
9
|
if (typeof body === "object" && body !== null)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { ReplyObject } from "
|
|
1
|
+
import type { ReplyObject } from "#/types/reply.js";
|
|
2
2
|
|
|
3
3
|
export const MS_PER_SECOND = 1000;
|
|
4
|
-
const BASE_36 = 36;
|
|
5
4
|
export const DEFAULT_USAGE = { input: 10, output: 5 } as const;
|
|
6
5
|
|
|
7
6
|
export function splitText(text: string, chunkSize: number): string[] {
|
|
@@ -13,8 +12,14 @@ export function splitText(text: string, chunkSize: number): string[] {
|
|
|
13
12
|
return chunks;
|
|
14
13
|
}
|
|
15
14
|
|
|
15
|
+
const ID_SUFFIX_LENGTH = 12;
|
|
16
|
+
|
|
17
|
+
function randomSuffix(): string {
|
|
18
|
+
return crypto.randomUUID().replaceAll("-", "").slice(0, ID_SUFFIX_LENGTH);
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export function genId(prefix: string): string {
|
|
17
|
-
return `${prefix}_${
|
|
22
|
+
return `${prefix}_${randomSuffix()}`;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export function toolId(
|
|
@@ -22,7 +27,7 @@ export function toolId(
|
|
|
22
27
|
prefix: string,
|
|
23
28
|
index: number,
|
|
24
29
|
): string {
|
|
25
|
-
return tool.id ?? `${prefix}_${
|
|
30
|
+
return tool.id ?? `${prefix}_${randomSuffix()}_${index}`;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function shouldEmitText(reply: ReplyObject): boolean {
|
package/src/formats/types.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
MockRequest,
|
|
4
|
-
ReplyObject,
|
|
5
|
-
ReplyOptions,
|
|
6
|
-
} from "../types.js";
|
|
1
|
+
import type { FormatName, MockRequest } from "#/types/request.js";
|
|
2
|
+
import type { ReplyObject, ReplyOptions } from "#/types/reply.js";
|
|
7
3
|
import type { RequestMeta } from "./request-helpers.js";
|
|
8
4
|
|
|
9
5
|
export interface SSEChunk {
|
package/src/history.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { MockRequest } from "./types.js";
|
|
1
|
+
import type { MockRequest } from "./types/request.js";
|
|
2
2
|
|
|
3
3
|
/** A recorded request with the rule that matched and when it happened. */
|
|
4
4
|
export interface RecordedRequest {
|
|
5
|
+
/** The normalised request that was received. */
|
|
5
6
|
readonly request: MockRequest;
|
|
6
|
-
/**
|
|
7
|
+
/** Description of the rule that matched, or `undefined` if the fallback was used. */
|
|
7
8
|
readonly rule: string | undefined;
|
|
9
|
+
/** When the request was recorded (`Date.now()` value). */
|
|
8
10
|
readonly timestamp: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
@@ -56,10 +58,12 @@ export class RequestHistory {
|
|
|
56
58
|
return this.entries;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/** Remove all recorded entries. */
|
|
59
62
|
clear(): void {
|
|
60
63
|
this.entries.length = 0;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/** Enables `for...of` iteration over recorded entries. */
|
|
63
67
|
[Symbol.iterator](): Iterator<RecordedRequest> {
|
|
64
68
|
return this.entries[Symbol.iterator]();
|
|
65
69
|
}
|
package/src/loader.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import { join, extname } from "node:path";
|
|
3
3
|
import JSON5 from "json5";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import type {
|
|
5
|
+
import type { Reply } from "./types/reply.js";
|
|
6
|
+
import type { Handler, Match, MatchObject } from "./types/rule.js";
|
|
6
7
|
import { type RuleEngine, createSequenceResolver } from "./rule-engine.js";
|
|
7
8
|
|
|
8
9
|
interface LoadContext {
|
package/src/mock-server.ts
CHANGED
|
@@ -1,46 +1,60 @@
|
|
|
1
1
|
import Fastify from "fastify";
|
|
2
2
|
import type { FastifyInstance } from "fastify";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
ReplyOptions,
|
|
8
|
-
Resolver,
|
|
9
|
-
Rule,
|
|
10
|
-
RuleHandle,
|
|
11
|
-
RuleSummary,
|
|
12
|
-
SequenceEntry,
|
|
13
|
-
} from "./types.js";
|
|
14
|
-
import { RuleEngine, createSequenceResolver } from "./rule-engine.js";
|
|
3
|
+
import type { Reply, ReplyOptions } from "./types/reply.js";
|
|
4
|
+
import type { RuleSummary } from "./types/rule.js";
|
|
5
|
+
import { RuleEngine } from "./rule-engine.js";
|
|
6
|
+
import { RuleBuilder } from "./rule-builder.js";
|
|
15
7
|
import { RequestHistory } from "./history.js";
|
|
16
|
-
import {
|
|
8
|
+
import { chatCompletionsFormat } from "./formats/openai/chat-completions/index.js";
|
|
17
9
|
import { anthropicFormat } from "./formats/anthropic/index.js";
|
|
18
|
-
import { responsesFormat } from "./formats/responses/index.js";
|
|
10
|
+
import { responsesFormat } from "./formats/openai/responses/index.js";
|
|
19
11
|
import type { Format } from "./formats/types.js";
|
|
20
12
|
import { Logger } from "./logger.js";
|
|
21
13
|
import type { LogLevel } from "./logger.js";
|
|
22
14
|
import { createRouteHandler } from "./route-handler.js";
|
|
23
15
|
|
|
24
16
|
const formats: readonly Format[] = [
|
|
25
|
-
|
|
17
|
+
chatCompletionsFormat,
|
|
26
18
|
anthropicFormat,
|
|
27
19
|
responsesFormat,
|
|
28
20
|
];
|
|
29
21
|
|
|
22
|
+
/** Options for constructing a `MockServer` or calling `createMock()`. */
|
|
30
23
|
export interface MockServerOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Port to listen on. Pass `0` for a random port (useful in tests).
|
|
26
|
+
* @defaultValue `0`
|
|
27
|
+
*/
|
|
31
28
|
readonly port?: number;
|
|
32
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Host to bind to. Set to `"0.0.0.0"` to listen on all interfaces.
|
|
31
|
+
* @defaultValue `"127.0.0.1"`
|
|
32
|
+
*/
|
|
33
33
|
readonly host?: string;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Log verbosity.
|
|
36
|
+
* @defaultValue `"none"`
|
|
37
|
+
*/
|
|
35
38
|
readonly logLevel?: LogLevel;
|
|
36
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Default ms delay between SSE chunks. Individual rules can override this.
|
|
41
|
+
* @defaultValue `0`
|
|
42
|
+
*/
|
|
37
43
|
readonly defaultLatency?: number;
|
|
38
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Default characters per SSE text chunk. Individual rules can override this.
|
|
46
|
+
* @defaultValue `0`
|
|
47
|
+
*/
|
|
39
48
|
readonly defaultChunkSize?: number;
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
type RuleAPI = Pick<
|
|
52
|
+
RuleBuilder,
|
|
53
|
+
"when" | "whenTool" | "whenToolResult" | "nextError"
|
|
54
|
+
>;
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
|
-
* Mock LLM server that handles OpenAI, Anthropic, and Responses API formats.
|
|
57
|
+
* Mock LLM server that handles OpenAI Chat Completions, Anthropic Messages, and OpenAI Responses API formats.
|
|
44
58
|
* Register rules with `when()`, point your SDK at `url`, and go.
|
|
45
59
|
*
|
|
46
60
|
* Supports `await using` for automatic cleanup.
|
|
@@ -54,9 +68,10 @@ export interface MockServerOptions {
|
|
|
54
68
|
* await server.stop();
|
|
55
69
|
* ```
|
|
56
70
|
*/
|
|
57
|
-
export class MockServer {
|
|
71
|
+
export class MockServer implements RuleAPI {
|
|
58
72
|
private readonly app: FastifyInstance;
|
|
59
73
|
private readonly engine = new RuleEngine();
|
|
74
|
+
private readonly rules_ = new RuleBuilder(this.engine);
|
|
60
75
|
private readonly history_ = new RequestHistory();
|
|
61
76
|
private readonly logger: Logger;
|
|
62
77
|
private readonly host: string;
|
|
@@ -64,6 +79,15 @@ export class MockServer {
|
|
|
64
79
|
private fallbackReply: Reply = "Mock server: no matching rule.";
|
|
65
80
|
private listening = false;
|
|
66
81
|
|
|
82
|
+
/** Register a matching rule. Call `.reply()` on the result to set the response. */
|
|
83
|
+
when = this.rules_.when.bind(this.rules_);
|
|
84
|
+
/** Shorthand for `when({ toolName })`. */
|
|
85
|
+
whenTool = this.rules_.whenTool.bind(this.rules_);
|
|
86
|
+
/** Shorthand for `when({ toolCallId })`. */
|
|
87
|
+
whenToolResult = this.rules_.whenToolResult.bind(this.rules_);
|
|
88
|
+
/** Queue a one-shot error for the very next request. Fires once then removes itself. */
|
|
89
|
+
nextError = this.rules_.nextError.bind(this.rules_);
|
|
90
|
+
|
|
67
91
|
constructor(options: MockServerOptions = {}) {
|
|
68
92
|
this.host = options.host ?? "127.0.0.1";
|
|
69
93
|
this.logger = new Logger(options.logLevel ?? "none");
|
|
@@ -91,92 +115,9 @@ export class MockServer {
|
|
|
91
115
|
}
|
|
92
116
|
|
|
93
117
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* @example
|
|
97
|
-
* ```ts
|
|
98
|
-
* server.when("hello").reply("Hi!");
|
|
99
|
-
* server.when(/explain (\w+)/i).reply((req) => `Let me explain ${req.lastMessage}`);
|
|
100
|
-
* server.when({ model: /claude/ }).reply("I'm Claude.");
|
|
101
|
-
* ```
|
|
102
|
-
*/
|
|
103
|
-
when(match: Match): PendingRule {
|
|
104
|
-
const engine = this.engine;
|
|
105
|
-
|
|
106
|
-
const makeHandle = (rule: Rule): RuleHandle => ({
|
|
107
|
-
times(n: number): RuleHandle {
|
|
108
|
-
rule.remaining = n;
|
|
109
|
-
return this;
|
|
110
|
-
},
|
|
111
|
-
first(): RuleHandle {
|
|
112
|
-
engine.moveToFront(rule);
|
|
113
|
-
return this;
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
reply(response: Resolver, options?: ReplyOptions): RuleHandle {
|
|
119
|
-
return makeHandle(engine.add(match, response, options));
|
|
120
|
-
},
|
|
121
|
-
replySequence(entries: readonly SequenceEntry[]): RuleHandle {
|
|
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;
|
|
131
|
-
return makeHandle(rule);
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Register a rule that matches when the request includes a tool with this name.
|
|
138
|
-
*
|
|
139
|
-
* @example
|
|
140
|
-
* ```ts
|
|
141
|
-
* server.whenTool("get_weather").reply({
|
|
142
|
-
* tools: [{ name: "get_weather", args: { location: "London" } }],
|
|
143
|
-
* });
|
|
144
|
-
* ```
|
|
145
|
-
*/
|
|
146
|
-
whenTool(toolName: string): PendingRule {
|
|
147
|
-
return this.when({ toolName });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Register a rule that matches when the last message is a tool result with this call ID.
|
|
152
|
-
*
|
|
153
|
-
* @example
|
|
154
|
-
* ```ts
|
|
155
|
-
* server.whenToolResult("call_abc").reply("Got your result, cheers!");
|
|
156
|
-
* ```
|
|
157
|
-
*/
|
|
158
|
-
whenToolResult(toolCallId: string): PendingRule {
|
|
159
|
-
return this.when({ toolCallId });
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Queue a one-shot error for the very next request, regardless of content.
|
|
164
|
-
* Fires once then removes itself.
|
|
165
|
-
*
|
|
166
|
-
* @example
|
|
167
|
-
* ```ts
|
|
168
|
-
* server.nextError(429, "Rate limited");
|
|
169
|
-
* // next request gets a 429, after that normal matching resumes
|
|
170
|
-
* ```
|
|
118
|
+
* Set the reply used when no rule matches.
|
|
119
|
+
* @defaultValue `"Mock server: no matching rule."`
|
|
171
120
|
*/
|
|
172
|
-
nextError(status: number, message: string, type?: string): RuleHandle {
|
|
173
|
-
return this.when(() => true)
|
|
174
|
-
.reply({ error: { status, message, type } })
|
|
175
|
-
.times(1)
|
|
176
|
-
.first();
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Set the reply used when no rule matches. Defaults to a generic message. */
|
|
180
121
|
fallback(reply: Reply): void {
|
|
181
122
|
this.fallbackReply = reply;
|
|
182
123
|
}
|
|
@@ -224,6 +165,12 @@ export class MockServer {
|
|
|
224
165
|
return `http://${this.host}:${port}`;
|
|
225
166
|
}
|
|
226
167
|
|
|
168
|
+
/** The API routes registered on this server, e.g. `["/v1/chat/completions", ...]`. */
|
|
169
|
+
get routes(): readonly string[] {
|
|
170
|
+
return formats.map((f) => f.route);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Number of currently registered rules. */
|
|
227
174
|
get ruleCount(): number {
|
|
228
175
|
return this.engine.ruleCount;
|
|
229
176
|
}
|
|
@@ -241,6 +188,7 @@ export class MockServer {
|
|
|
241
188
|
this.logger.info(`Listening on ${this.url}`);
|
|
242
189
|
}
|
|
243
190
|
|
|
191
|
+
/** Stop the server. Safe to call multiple times. */
|
|
244
192
|
async stop(): Promise<void> {
|
|
245
193
|
if (!this.listening) return;
|
|
246
194
|
await this.app.close();
|
|
@@ -248,6 +196,7 @@ export class MockServer {
|
|
|
248
196
|
this.logger.info("Server stopped");
|
|
249
197
|
}
|
|
250
198
|
|
|
199
|
+
/** Calls `stop()`. Enables `await using server = ...` for automatic cleanup. */
|
|
251
200
|
async [Symbol.asyncDispose](): Promise<void> {
|
|
252
201
|
await this.stop();
|
|
253
202
|
}
|
package/src/route-handler.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
2
|
import { ZodError } from "zod";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ReplyOptions,
|
|
7
|
-
MockRequest,
|
|
8
|
-
Rule,
|
|
9
|
-
} from "./types.js";
|
|
3
|
+
import type { MockRequest } from "./types/request.js";
|
|
4
|
+
import type { Reply, ReplyObject, ReplyOptions } from "./types/reply.js";
|
|
5
|
+
import type { Rule } from "./types/rule.js";
|
|
10
6
|
import type { Format } from "./formats/types.js";
|
|
11
7
|
import type { RuleEngine } from "./rule-engine.js";
|
|
12
8
|
import type { RequestHistory } from "./history.js";
|
|
@@ -15,7 +11,7 @@ import { writeSSE } from "./sse-writer.js";
|
|
|
15
11
|
|
|
16
12
|
const HTTP_BAD_REQUEST = 400;
|
|
17
13
|
|
|
18
|
-
function
|
|
14
|
+
function normaliseReply(reply: Reply): ReplyObject {
|
|
19
15
|
if (typeof reply === "string") return { text: reply };
|
|
20
16
|
return reply;
|
|
21
17
|
}
|
|
@@ -30,7 +26,7 @@ async function resolveReply(
|
|
|
30
26
|
logger.warn(
|
|
31
27
|
`No matching rule for "${mockReq.lastMessage}", using fallback`,
|
|
32
28
|
);
|
|
33
|
-
return { reply:
|
|
29
|
+
return { reply: normaliseReply(fallback), ruleDesc: undefined };
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
try {
|
|
@@ -39,10 +35,10 @@ async function resolveReply(
|
|
|
39
35
|
? await matched.resolve(mockReq)
|
|
40
36
|
: matched.resolve;
|
|
41
37
|
logger.debug(`Matched rule ${matched.description}`);
|
|
42
|
-
return { reply:
|
|
38
|
+
return { reply: normaliseReply(raw), ruleDesc: matched.description };
|
|
43
39
|
} catch (err) {
|
|
44
40
|
logger.error(`Resolver threw for rule ${matched.description}`, err);
|
|
45
|
-
return { reply:
|
|
41
|
+
return { reply: normaliseReply(fallback), ruleDesc: matched.description };
|
|
46
42
|
}
|
|
47
43
|
}
|
|
48
44
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Reply,
|
|
3
|
+
ReplyOptions,
|
|
4
|
+
Resolver,
|
|
5
|
+
SequenceEntry,
|
|
6
|
+
} from "./types/reply.js";
|
|
7
|
+
import type { Match, PendingRule, Rule, RuleHandle } from "./types/rule.js";
|
|
8
|
+
import type { RuleEngine } from "./rule-engine.js";
|
|
9
|
+
import { createSequenceResolver } from "./rule-engine.js";
|
|
10
|
+
|
|
11
|
+
function makeHandle(engine: RuleEngine, rule: Rule): RuleHandle {
|
|
12
|
+
return {
|
|
13
|
+
times(n: number): RuleHandle {
|
|
14
|
+
rule.remaining = n;
|
|
15
|
+
return this;
|
|
16
|
+
},
|
|
17
|
+
first(): RuleHandle {
|
|
18
|
+
engine.moveToFront(rule);
|
|
19
|
+
return this;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Builds matching rules against the rule engine. Proxied onto `MockServer`. */
|
|
25
|
+
export class RuleBuilder {
|
|
26
|
+
constructor(private readonly engine: RuleEngine) {}
|
|
27
|
+
|
|
28
|
+
/** Register a matching rule. Call `.reply()` on the result to set the response. */
|
|
29
|
+
when(match: Match): PendingRule {
|
|
30
|
+
const engine = this.engine;
|
|
31
|
+
return {
|
|
32
|
+
reply(response: Resolver, options?: ReplyOptions): RuleHandle {
|
|
33
|
+
return makeHandle(engine, engine.add(match, response, options));
|
|
34
|
+
},
|
|
35
|
+
replySequence(entries: readonly SequenceEntry[]): RuleHandle {
|
|
36
|
+
const steps = normaliseSequenceEntries(entries);
|
|
37
|
+
const rule = engine.add(match, "");
|
|
38
|
+
const { resolver, entryCount } = createSequenceResolver(steps, rule);
|
|
39
|
+
rule.resolve = resolver;
|
|
40
|
+
rule.remaining = entryCount;
|
|
41
|
+
return makeHandle(engine, rule);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shorthand for `when({ toolName })`. */
|
|
47
|
+
whenTool(toolName: string): PendingRule {
|
|
48
|
+
return this.when({ toolName });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shorthand for `when({ toolCallId })`. */
|
|
52
|
+
whenToolResult(toolCallId: string): PendingRule {
|
|
53
|
+
return this.when({ toolCallId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Queue a one-shot error for the very next request. Fires once then removes itself. */
|
|
57
|
+
nextError(status: number, message: string, type?: string): RuleHandle {
|
|
58
|
+
return this.when(() => true)
|
|
59
|
+
.reply({ error: { status, message, type } })
|
|
60
|
+
.times(1)
|
|
61
|
+
.first();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normaliseSequenceEntries(
|
|
66
|
+
entries: readonly SequenceEntry[],
|
|
67
|
+
): { reply: Reply; options?: ReplyOptions | undefined }[] {
|
|
68
|
+
return entries.map((entry) =>
|
|
69
|
+
typeof entry === "string" || !("reply" in entry)
|
|
70
|
+
? { reply: entry as Reply }
|
|
71
|
+
: { reply: entry.reply, options: entry.options },
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/rule-engine.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
MockRequest,
|
|
5
|
-
Resolver,
|
|
6
|
-
Reply,
|
|
7
|
-
ReplyOptions,
|
|
8
|
-
Rule,
|
|
9
|
-
RuleSummary,
|
|
10
|
-
} from "./types.js";
|
|
1
|
+
import type { MockRequest } from "./types/request.js";
|
|
2
|
+
import type { Reply, ReplyOptions, Resolver } from "./types/reply.js";
|
|
3
|
+
import type { Match, MatchObject, Rule, RuleSummary } from "./types/rule.js";
|
|
11
4
|
|
|
12
5
|
function safeRegex(re: RegExp): RegExp {
|
|
13
6
|
return re.global || re.sticky
|
package/src/sse-writer.ts
CHANGED
package/src/types/reply.ts
CHANGED
|
@@ -3,13 +3,28 @@ import type { MockRequest } from "./request.js";
|
|
|
3
3
|
/** A reply is either a plain string (turns into `{ text: "..." }`) or a full reply object. */
|
|
4
4
|
export type Reply = string | ReplyObject;
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* A structured reply. Text, reasoning, tool calls, usage, and errors are all optional.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* server.when("hello").reply({ text: "Hi!", reasoning: "Simple greeting." });
|
|
12
|
+
* server.when("weather").reply({
|
|
13
|
+
* tools: [{ name: "get_weather", args: { city: "London" } }],
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
7
17
|
export interface ReplyObject {
|
|
18
|
+
/** Text content to send back. */
|
|
8
19
|
readonly text?: string | undefined;
|
|
9
20
|
/** Extended thinking or chain-of-thought. Works with Anthropic and Responses formats. */
|
|
10
21
|
readonly reasoning?: string | undefined;
|
|
22
|
+
/** Tool calls the model wants to make. */
|
|
11
23
|
readonly tools?: readonly ToolCall[] | undefined;
|
|
12
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Token counts to report.
|
|
26
|
+
* @defaultValue `{ input: 10, output: 5 }`
|
|
27
|
+
*/
|
|
13
28
|
readonly usage?:
|
|
14
29
|
| { readonly input: number; readonly output: number }
|
|
15
30
|
| undefined;
|
|
@@ -19,31 +34,59 @@ export interface ReplyObject {
|
|
|
19
34
|
|
|
20
35
|
/** An HTTP error response. The server returns this status code with a format-appropriate body. */
|
|
21
36
|
export interface ErrorReply {
|
|
37
|
+
/** HTTP status code, e.g. `429` or `500`. */
|
|
22
38
|
readonly status: number;
|
|
39
|
+
/** Error message in the response body. */
|
|
23
40
|
readonly message: string;
|
|
24
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Error type string in the response body.
|
|
43
|
+
* @defaultValue Format-specific (e.g. `"server_error"` for OpenAI, `"api_error"` for Anthropic).
|
|
44
|
+
*/
|
|
25
45
|
readonly type?: string | undefined;
|
|
26
46
|
}
|
|
27
47
|
|
|
48
|
+
/** A tool call in the mock response. */
|
|
28
49
|
export interface ToolCall {
|
|
29
|
-
/** Auto-generated if omitted. */
|
|
50
|
+
/** Explicit ID for the call. Auto-generated if omitted. */
|
|
30
51
|
readonly id?: string | undefined;
|
|
52
|
+
/** Tool function name. */
|
|
31
53
|
readonly name: string;
|
|
54
|
+
/** Arguments to pass to the tool. */
|
|
32
55
|
readonly args: Readonly<Record<string, unknown>>;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* A reply value or a function that produces one. Async functions are supported.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* server.when("echo").reply((req) => `You said: ${req.lastMessage}`);
|
|
64
|
+
* server.when("slow").reply(async (req) => {
|
|
65
|
+
* return { text: "Done thinking." };
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
36
69
|
export type Resolver = Reply | ((req: MockRequest) => Reply | Promise<Reply>);
|
|
37
70
|
|
|
38
71
|
/** Per-rule streaming options. Merged with server-level defaults, with per-rule values winning. */
|
|
39
72
|
export interface ReplyOptions {
|
|
40
|
-
/** Milliseconds between SSE chunks. */
|
|
73
|
+
/** Milliseconds to wait between SSE chunks. */
|
|
41
74
|
readonly latency?: number | undefined;
|
|
42
|
-
/**
|
|
75
|
+
/** Split text into chunks of this many characters for more realistic streaming. */
|
|
43
76
|
readonly chunkSize?: number | undefined;
|
|
44
77
|
}
|
|
45
78
|
|
|
46
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* A single entry in a reply sequence. Can be a plain reply or a reply with per-step options.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* server.when("step").replySequence([
|
|
85
|
+
* "Starting.",
|
|
86
|
+
* { reply: "Done.", options: { latency: 100 } },
|
|
87
|
+
* ]);
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
47
90
|
export type SequenceEntry =
|
|
48
91
|
| Reply
|
|
49
92
|
| { readonly reply: Reply; readonly options?: ReplyOptions };
|