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.
Files changed (113) hide show
  1. package/.desloppify/query.json +1162 -62
  2. package/.desloppify/review_packet_blind.json +18 -18
  3. package/.desloppify/review_packets/holistic_packet_20260315_185401.json +1407 -0
  4. package/.desloppify/review_packets/holistic_packet_20260315_185613.json +1407 -0
  5. package/.desloppify/state-typescript.json +2530 -645
  6. package/.desloppify/state-typescript.json.bak +2494 -582
  7. package/.desloppify/subagents/runs/20260315_185401/logs/batch-1.log +384 -0
  8. package/.desloppify/subagents/runs/20260315_185401/logs/batch-10.log +484 -0
  9. package/.desloppify/subagents/runs/20260315_185401/logs/batch-2.log +408 -0
  10. package/.desloppify/subagents/runs/20260315_185401/logs/batch-3.log +416 -0
  11. package/.desloppify/subagents/runs/20260315_185401/logs/batch-4.log +360 -0
  12. package/.desloppify/subagents/runs/20260315_185401/logs/batch-5.log +360 -0
  13. package/.desloppify/subagents/runs/20260315_185401/logs/batch-6.log +364 -0
  14. package/.desloppify/subagents/runs/20260315_185401/logs/batch-7.log +428 -0
  15. package/.desloppify/subagents/runs/20260315_185401/logs/batch-8.log +388 -0
  16. package/.desloppify/subagents/runs/20260315_185401/logs/batch-9.log +500 -0
  17. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-1.md +83 -0
  18. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-10.md +108 -0
  19. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-2.md +89 -0
  20. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-3.md +91 -0
  21. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-4.md +77 -0
  22. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-5.md +77 -0
  23. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-6.md +78 -0
  24. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-7.md +94 -0
  25. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-8.md +84 -0
  26. package/.desloppify/subagents/runs/20260315_185401/prompts/batch-9.md +112 -0
  27. package/.desloppify/subagents/runs/20260315_185401/results/batch-1.raw.txt +0 -0
  28. package/.desloppify/subagents/runs/20260315_185401/results/batch-10.raw.txt +0 -0
  29. package/.desloppify/subagents/runs/20260315_185401/results/batch-2.raw.txt +0 -0
  30. package/.desloppify/subagents/runs/20260315_185401/results/batch-3.raw.txt +0 -0
  31. package/.desloppify/subagents/runs/20260315_185401/results/batch-4.raw.txt +0 -0
  32. package/.desloppify/subagents/runs/20260315_185401/results/batch-5.raw.txt +0 -0
  33. package/.desloppify/subagents/runs/20260315_185401/results/batch-6.raw.txt +0 -0
  34. package/.desloppify/subagents/runs/20260315_185401/results/batch-7.raw.txt +0 -0
  35. package/.desloppify/subagents/runs/20260315_185401/results/batch-8.raw.txt +0 -0
  36. package/.desloppify/subagents/runs/20260315_185401/results/batch-9.raw.txt +0 -0
  37. package/.desloppify/subagents/runs/20260315_185401/run.log +36 -0
  38. package/.desloppify/subagents/runs/20260315_185401/run_summary.json +156 -0
  39. package/.desloppify/subagents/runs/20260315_185613/holistic_findings_merged.json +741 -0
  40. package/.desloppify/subagents/runs/20260315_185613/logs/batch-1.log +579 -0
  41. package/.desloppify/subagents/runs/20260315_185613/logs/batch-10.log +1537 -0
  42. package/.desloppify/subagents/runs/20260315_185613/logs/batch-2.log +829 -0
  43. package/.desloppify/subagents/runs/20260315_185613/logs/batch-3.log +927 -0
  44. package/.desloppify/subagents/runs/20260315_185613/logs/batch-4.log +429 -0
  45. package/.desloppify/subagents/runs/20260315_185613/logs/batch-5.log +276 -0
  46. package/.desloppify/subagents/runs/20260315_185613/logs/batch-6.log +450 -0
  47. package/.desloppify/subagents/runs/20260315_185613/logs/batch-7.log +730 -0
  48. package/.desloppify/subagents/runs/20260315_185613/logs/batch-8.log +698 -0
  49. package/.desloppify/subagents/runs/20260315_185613/logs/batch-9.log +938 -0
  50. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-1.md +83 -0
  51. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-10.md +108 -0
  52. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-2.md +89 -0
  53. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-3.md +91 -0
  54. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-4.md +77 -0
  55. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-5.md +77 -0
  56. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-6.md +78 -0
  57. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-7.md +94 -0
  58. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-8.md +84 -0
  59. package/.desloppify/subagents/runs/20260315_185613/prompts/batch-9.md +112 -0
  60. package/.desloppify/subagents/runs/20260315_185613/results/batch-1.raw.txt +78 -0
  61. package/.desloppify/subagents/runs/20260315_185613/results/batch-10.raw.txt +242 -0
  62. package/.desloppify/subagents/runs/20260315_185613/results/batch-2.raw.txt +102 -0
  63. package/.desloppify/subagents/runs/20260315_185613/results/batch-3.raw.txt +94 -0
  64. package/.desloppify/subagents/runs/20260315_185613/results/batch-4.raw.txt +86 -0
  65. package/.desloppify/subagents/runs/20260315_185613/results/batch-5.raw.txt +1 -0
  66. package/.desloppify/subagents/runs/20260315_185613/results/batch-6.raw.txt +87 -0
  67. package/.desloppify/subagents/runs/20260315_185613/results/batch-7.raw.txt +1 -0
  68. package/.desloppify/subagents/runs/20260315_185613/results/batch-8.raw.txt +107 -0
  69. package/.desloppify/subagents/runs/20260315_185613/results/batch-9.raw.txt +67 -0
  70. package/.desloppify/subagents/runs/20260315_185613/run.log +96 -0
  71. package/.desloppify/subagents/runs/20260315_185613/run_summary.json +156 -0
  72. package/.github/workflows/docs.yml +46 -0
  73. package/.github/workflows/test.yml +3 -0
  74. package/README.md +8 -4
  75. package/docs/ARCHITECTURE.md +11 -11
  76. package/package.json +18 -11
  77. package/scorecard.png +0 -0
  78. package/src/{cli.ts → cli/cli.ts} +6 -11
  79. package/src/{cli-validators.ts → cli/validators.ts} +10 -9
  80. package/src/formats/anthropic/index.ts +2 -2
  81. package/src/formats/anthropic/parse.ts +5 -2
  82. package/src/formats/anthropic/serialize.ts +3 -3
  83. package/src/formats/openai/{index.ts → chat-completions/index.ts} +3 -3
  84. package/src/formats/openai/{parse.ts → chat-completions/parse.ts} +5 -2
  85. package/src/formats/openai/{serialize.ts → chat-completions/serialize.ts} +3 -3
  86. package/src/formats/{responses → openai/responses}/index.ts +2 -2
  87. package/src/formats/{responses → openai/responses}/parse.ts +5 -2
  88. package/src/formats/{responses → openai/responses}/serialize.ts +3 -3
  89. package/src/formats/request-helpers.ts +6 -1
  90. package/src/formats/serialize-helpers.ts +9 -4
  91. package/src/formats/types.ts +2 -6
  92. package/src/history.ts +6 -2
  93. package/src/loader.ts +2 -1
  94. package/src/mock-server.ts +55 -106
  95. package/src/route-handler.ts +7 -11
  96. package/src/rule-builder.ts +73 -0
  97. package/src/rule-engine.ts +3 -10
  98. package/src/sse-writer.ts +1 -1
  99. package/src/types/reply.ts +51 -8
  100. package/src/types/request.ts +21 -6
  101. package/src/types/rule.ts +65 -7
  102. package/test/cli-validators.test.ts +13 -5
  103. package/test/formats/openai.test.ts +40 -28
  104. package/test/formats/responses.test.ts +2 -2
  105. package/test/history.test.ts +1 -1
  106. package/test/loader.test.ts +3 -3
  107. package/test/logger.test.ts +2 -2
  108. package/test/mock-server.test.ts +1 -1
  109. package/test/rule-engine.test.ts +1 -1
  110. package/tsconfig.json +2 -4
  111. package/typedoc.json +9 -0
  112. /package/src/formats/openai/{schema.ts → chat-completions/schema.ts} +0 -0
  113. /package/src/formats/{responses → openai/responses}/schema.ts +0 -0
@@ -1,5 +1,5 @@
1
- import type { ReplyObject, ReplyOptions } from "../../types.js";
2
- import type { SSEChunk } from "../types.js";
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 "../serialize-helpers.js";
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 "../types.js";
2
- import { isStreaming } from "../request-helpers.js";
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 openaiFormat: Format = {
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 "../../types.js";
2
- import { buildMockRequest, type RequestMeta } from "../request-helpers.js";
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 "../../types.js";
2
- import type { SSEChunk } from "../types.js";
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 "../serialize-helpers.js";
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 "../types.js";
2
- import { isStreaming } from "../request-helpers.js";
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 "../../types.js";
2
- import { buildMockRequest, type RequestMeta } from "../request-helpers.js";
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 "../../types.js";
2
- import type { SSEChunk } from "../types.js";
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 "../serialize-helpers.js";
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 { FormatName, Message, MockRequest, ToolDef } from "../types.js";
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 "../types.js";
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}_${Date.now().toString(BASE_36)}`;
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}_${Date.now().toString(BASE_36)}_${index}`;
30
+ return tool.id ?? `${prefix}_${randomSuffix()}_${index}`;
26
31
  }
27
32
 
28
33
  export function shouldEmitText(reply: ReplyObject): boolean {
@@ -1,9 +1,5 @@
1
- import type {
2
- FormatName,
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
- /** The rule that matched, or `undefined` if the fallback was used. */
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 { Handler, Match, MatchObject, Reply } from "./types.js";
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 {
@@ -1,46 +1,60 @@
1
1
  import Fastify from "fastify";
2
2
  import type { FastifyInstance } from "fastify";
3
- import type {
4
- Match,
5
- PendingRule,
6
- Reply,
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 { openaiFormat } from "./formats/openai/index.js";
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
- openaiFormat,
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
- /** Defaults to `"127.0.0.1"`. Set to `"0.0.0.0"` to listen on all interfaces. */
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
- /** Defaults to `"none"` (silent). */
34
+ /**
35
+ * Log verbosity.
36
+ * @defaultValue `"none"`
37
+ */
35
38
  readonly logLevel?: LogLevel;
36
- /** Default ms delay between SSE chunks. Individual rules can override this. */
39
+ /**
40
+ * Default ms delay between SSE chunks. Individual rules can override this.
41
+ * @defaultValue `0`
42
+ */
37
43
  readonly defaultLatency?: number;
38
- /** Default characters per SSE text chunk. Individual rules can override this. */
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
- * Register a matching rule. Call `.reply()` on the result to set the response.
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
  }
@@ -1,12 +1,8 @@
1
1
  import type { FastifyReply, FastifyRequest } from "fastify";
2
2
  import { ZodError } from "zod";
3
- import type {
4
- Reply,
5
- ReplyObject,
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 normalizeReply(reply: Reply): ReplyObject {
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: normalizeReply(fallback), ruleDesc: undefined };
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: normalizeReply(raw), ruleDesc: matched.description };
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: normalizeReply(fallback), ruleDesc: matched.description };
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
+ }
@@ -1,13 +1,6 @@
1
- import type {
2
- Match,
3
- MatchObject,
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
@@ -1,6 +1,6 @@
1
1
  import type { FastifyReply } from "fastify";
2
2
  import type { SSEChunk } from "./formats/types.js";
3
- import type { ReplyOptions } from "./types.js";
3
+ import type { ReplyOptions } from "./types/reply.js";
4
4
 
5
5
  const HTTP_OK = 200;
6
6
 
@@ -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
- /** A structured reply. Text, reasoning, tool calls, usage, and errors are all optional. */
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
- /** Falls back to `{ input: 10, output: 5 }` if omitted. */
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
- /** Each format has its own default if omitted. */
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
- /** A reply value or a function that produces one. Async functions are supported. */
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
- /** Characters per SSE chunk for more realistic streaming. */
75
+ /** Split text into chunks of this many characters for more realistic streaming. */
43
76
  readonly chunkSize?: number | undefined;
44
77
  }
45
78
 
46
- /** A single entry in a reply sequence. Can be a plain reply or a reply with per-step options. */
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 };