llm-mock-server 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/canonical_import_20260315_050000.json +286 -0
  2. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/canonical_import_20260315_050028.json +303 -0
  3. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/claude_launch_prompt.md +17 -0
  4. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/review_result.json +297 -0
  5. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/review_result.template.json +22 -0
  6. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/reviewer_instructions.md +20 -0
  7. package/.desloppify/external_review_sessions/ext_20260315_045546_0587ea3b/session.json +20 -0
  8. package/.desloppify/query.json +31 -103
  9. package/.desloppify/review_packet_blind.json +134 -188
  10. package/.desloppify/review_packets/holistic_packet_20260315_045546.json +1480 -0
  11. package/.desloppify/state-typescript.json +2285 -846
  12. package/.desloppify/state-typescript.json.bak +2252 -840
  13. package/.editorconfig +12 -0
  14. package/.github/workflows/test.yml +3 -0
  15. package/.oxfmtrc.json +9 -0
  16. package/README.md +5 -0
  17. package/package.json +5 -2
  18. package/scorecard.png +0 -0
  19. package/src/cli-validators.ts +12 -4
  20. package/src/cli.ts +25 -11
  21. package/src/formats/anthropic/parse.ts +24 -5
  22. package/src/formats/anthropic/schema.ts +16 -8
  23. package/src/formats/anthropic/serialize.ts +112 -28
  24. package/src/formats/openai/parse.ts +12 -2
  25. package/src/formats/openai/schema.ts +43 -30
  26. package/src/formats/openai/serialize.ts +73 -17
  27. package/src/formats/request-helpers.ts +2 -1
  28. package/src/formats/responses/parse.ts +17 -3
  29. package/src/formats/responses/schema.ts +34 -20
  30. package/src/formats/responses/serialize.ts +235 -40
  31. package/src/formats/serialize-helpers.ts +10 -2
  32. package/src/formats/types.ts +16 -3
  33. package/src/index.ts +3 -1
  34. package/src/loader.ts +48 -12
  35. package/src/logger.ts +25 -7
  36. package/src/mock-server.ts +28 -7
  37. package/src/route-handler.ts +49 -14
  38. package/src/rule-engine.ts +43 -12
  39. package/src/types/reply.ts +6 -2
  40. package/src/types.ts +24 -3
  41. package/test/cli-validators.test.ts +16 -4
  42. package/test/formats/anthropic.test.ts +95 -19
  43. package/test/formats/openai.test.ts +85 -24
  44. package/test/formats/parse-helpers.test.ts +47 -7
  45. package/test/formats/responses.test.ts +111 -30
  46. package/test/history.test.ts +18 -5
  47. package/test/loader.test.ts +52 -17
  48. package/test/logger.test.ts +59 -9
  49. package/test/mock-server.test.ts +76 -22
  50. package/test/rule-engine.test.ts +49 -19
  51. /package/{ARCHITECTURE.md → docs/ARCHITECTURE.md} +0 -0
package/.editorconfig ADDED
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ indent_size = 2
6
+ end_of_line = lf
7
+ charset = utf-8
8
+ trim_trailing_whitespace = true
9
+ insert_final_newline = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
@@ -27,6 +27,9 @@ jobs:
27
27
  - name: Type check
28
28
  run: tsc --noEmit && tsc --noEmit -p tsconfig.test.json
29
29
 
30
+ - name: Format check
31
+ run: npm run fmt:check
32
+
30
33
  - name: Lint
31
34
  run: npm run lint
32
35
 
package/.oxfmtrc.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": false,
4
+ "trailingComma": "all",
5
+ "printWidth": 80,
6
+ "tabWidth": 2,
7
+ "useTabs": false,
8
+ "ignorePatterns": ["dist/", "coverage/"]
9
+ }
package/README.md CHANGED
@@ -24,6 +24,7 @@ A mock LLM server for testing. It handles OpenAI `/chat/completions`, Anthropic
24
24
  - [Logging](#logging)
25
25
  - [CLI](#cli)
26
26
  - [Security](#security)
27
+ - [Architecture](#architecture)
27
28
  - [Licence](#licence)
28
29
 
29
30
  ## Quick start
@@ -423,6 +424,10 @@ The server binds to `127.0.0.1` by default, so it's only reachable from your mac
423
424
 
424
425
  Request bodies are capped at 1 MB by Fastify's default. Responses are serialised through JSON, so there's no injection risk in the SSE output.
425
426
 
427
+ ## Architecture
428
+
429
+ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for how the codebase is structured, the request lifecycle, rule matching, and response serialisation.
430
+
426
431
  ## Licence
427
432
 
428
433
  MIT License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-mock-server",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A standalone mock LLM server for deterministic testing: OpenAI, Anthropic, and Responses API formats",
5
5
  "type": "module",
6
6
  "engines": {
@@ -20,10 +20,12 @@
20
20
  },
21
21
  "scripts": {
22
22
  "build": "tsc",
23
+ "fmt": "oxfmt --write src/ test/",
24
+ "fmt:check": "oxfmt --check src/ test/",
23
25
  "lint": "oxlint --tsconfig tsconfig.json --import-plugin --vitest-plugin src/ test/",
24
26
  "test": "vitest run",
25
27
  "test:watch": "vitest",
26
- "check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && npm run lint && npm test",
28
+ "check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && npm run fmt:check && npm run lint && npm test",
27
29
  "dev": "tsx src/cli.ts",
28
30
  "start": "node dist/cli.js"
29
31
  },
@@ -48,6 +50,7 @@
48
50
  "devDependencies": {
49
51
  "@types/node": "25.5.0",
50
52
  "@vitest/coverage-v8": "4.1.0",
53
+ "oxfmt": "0.40.0",
51
54
  "oxlint": "1.55.0",
52
55
  "tsx": "4.21.0",
53
56
  "typescript": "5.9.3",
package/scorecard.png CHANGED
Binary file
@@ -29,7 +29,9 @@ export function parseLogLevel(value: string): LogLevel {
29
29
 
30
30
  export async function parseHost(value: string): Promise<string> {
31
31
  if (!value) {
32
- throw new Error(`Invalid host "${value}". Must be a resolvable hostname or IP address.`);
32
+ throw new Error(
33
+ `Invalid host "${value}". Must be a resolvable hostname or IP address.`,
34
+ );
33
35
  }
34
36
  if (value === "localhost" || isIP(value) !== 0) {
35
37
  return value;
@@ -38,14 +40,18 @@ export async function parseHost(value: string): Promise<string> {
38
40
  await lookup(value);
39
41
  return value;
40
42
  } catch {
41
- throw new Error(`Invalid host "${value}". Must be a resolvable hostname or IP address.`);
43
+ throw new Error(
44
+ `Invalid host "${value}". Must be a resolvable hostname or IP address.`,
45
+ );
42
46
  }
43
47
  }
44
48
 
45
49
  export function parseChunkSize(value: string): number {
46
50
  const size = parseInt(value, 10);
47
51
  if (isNaN(size) || size < 0) {
48
- throw new Error(`Invalid chunk size "${value}". Must be a non-negative integer.`);
52
+ throw new Error(
53
+ `Invalid chunk size "${value}". Must be a non-negative integer.`,
54
+ );
49
55
  }
50
56
  return size;
51
57
  }
@@ -53,7 +59,9 @@ export function parseChunkSize(value: string): number {
53
59
  export function parseLatency(value: string): number {
54
60
  const ms = parseInt(value, 10);
55
61
  if (isNaN(ms) || ms < 0) {
56
- throw new Error(`Invalid latency "${value}". Must be a non-negative integer (ms).`);
62
+ throw new Error(
63
+ `Invalid latency "${value}". Must be a non-negative integer (ms).`,
64
+ );
57
65
  }
58
66
  return ms;
59
67
  }
package/src/cli.ts CHANGED
@@ -6,7 +6,13 @@ import { Command } from "commander";
6
6
  import pc from "picocolors";
7
7
  import { MockServer } from "./mock-server.js";
8
8
  import { Logger } from "./logger.js";
9
- import { parsePort, parseHost, parseLogLevel, parseChunkSize, parseLatency } from "./cli-validators.js";
9
+ import {
10
+ parsePort,
11
+ parseHost,
12
+ parseLogLevel,
13
+ parseChunkSize,
14
+ parseLatency,
15
+ } from "./cli-validators.js";
10
16
 
11
17
  const require = createRequire(import.meta.url);
12
18
  const { version } = require("../package.json") as { version: string };
@@ -58,12 +64,18 @@ async function start(options: StartOptions): Promise<void> {
58
64
 
59
65
  if (!quiet) {
60
66
  console.log();
61
- console.log(` ${pc.bold(pc.cyan("llm-mock-server"))} ${pc.dim(`v${version}`)}`);
67
+ console.log(
68
+ ` ${pc.bold(pc.cyan("llm-mock-server"))} ${pc.dim(`v${version}`)}`,
69
+ );
62
70
  console.log();
63
71
  console.log(` ${pc.dim("Port")} ${pc.bold(String(port))}`);
64
- console.log(` ${pc.dim("Rules")} ${pc.bold(String(server.ruleCount))} loaded`);
72
+ console.log(
73
+ ` ${pc.dim("Rules")} ${pc.bold(String(server.ruleCount))} loaded`,
74
+ );
65
75
  if (latency > 0) {
66
- console.log(` ${pc.dim("Latency")} ${pc.bold(`${String(latency)}ms`)} per chunk`);
76
+ console.log(
77
+ ` ${pc.dim("Latency")} ${pc.bold(`${String(latency)}ms`)} per chunk`,
78
+ );
67
79
  }
68
80
  console.log(
69
81
  ` ${pc.dim("Endpoints")} ${pc.green("/v1/chat/completions")}, ${pc.green("/v1/messages")}, ${pc.green("/v1/responses")}`,
@@ -73,11 +85,10 @@ async function start(options: StartOptions): Promise<void> {
73
85
 
74
86
  if (options.watch && options.rules) {
75
87
  const rulesPath = options.rules;
76
- let reloading = false;
88
+ let timer: ReturnType<typeof setTimeout> | undefined;
77
89
  watch(rulesPath, { recursive: true }, () => {
78
- if (reloading) return;
79
- reloading = true;
80
- setTimeout(async () => {
90
+ clearTimeout(timer);
91
+ timer = setTimeout(async () => {
81
92
  try {
82
93
  server.reset();
83
94
  await server.load(rulesPath);
@@ -86,7 +97,6 @@ async function start(options: StartOptions): Promise<void> {
86
97
  } catch (err) {
87
98
  logger.error("Failed to reload rules", err);
88
99
  }
89
- reloading = false;
90
100
  }, WATCH_DEBOUNCE_MS);
91
101
  });
92
102
  logger.info(`Watching ${rulesPath} for changes`);
@@ -103,8 +113,12 @@ async function start(options: StartOptions): Promise<void> {
103
113
  process.exit(0);
104
114
  };
105
115
 
106
- process.on("SIGINT", () => { shutdown("SIGINT").catch(() => process.exit(1)); });
107
- process.on("SIGTERM", () => { shutdown("SIGTERM").catch(() => process.exit(1)); });
116
+ process.on("SIGINT", () => {
117
+ shutdown("SIGINT").catch(() => process.exit(1));
118
+ });
119
+ process.on("SIGTERM", () => {
120
+ shutdown("SIGTERM").catch(() => process.exit(1));
121
+ });
108
122
  }
109
123
 
110
124
  const program = new Command()
@@ -4,18 +4,27 @@ import { AnthropicRequestSchema, type AnthropicRequest } from "./schema.js";
4
4
 
5
5
  function extractSystem(system: AnthropicRequest["system"]): Message[] {
6
6
  if (system == null) return [];
7
- if (typeof system === "string") return system ? [{ role: "system", content: system }] : [];
7
+ if (typeof system === "string")
8
+ return system ? [{ role: "system", content: system }] : [];
8
9
  const text = system.map((b) => b.text).join("\n");
9
10
  return text ? [{ role: "system", content: text }] : [];
10
11
  }
11
12
 
12
- function extractContent(content: AnthropicRequest["messages"][number]["content"]): { content: string; toolCallId?: string | undefined } {
13
+ function extractContent(
14
+ content: AnthropicRequest["messages"][number]["content"],
15
+ ): {
16
+ content: string;
17
+ toolCallId?: string | undefined;
18
+ } {
13
19
  if (typeof content === "string") return { content };
14
20
  const text = content
15
21
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
16
22
  .map((b) => b.text)
17
23
  .join("\n");
18
- const toolResult = content.find((b): b is { type: "tool_result"; tool_use_id: string } => b.type === "tool_result");
24
+ const toolResult = content.find(
25
+ (b): b is { type: "tool_result"; tool_use_id: string } =>
26
+ b.type === "tool_result",
27
+ );
19
28
  const toolCallId = toolResult?.tool_use_id;
20
29
  return { content: text, toolCallId };
21
30
  }
@@ -27,7 +36,9 @@ function parseMessages(req: AnthropicRequest): readonly Message[] {
27
36
  return {
28
37
  role: m.role,
29
38
  content: extracted.content,
30
- ...(extracted.toolCallId !== undefined && { toolCallId: extracted.toolCallId }),
39
+ ...(extracted.toolCallId !== undefined && {
40
+ toolCallId: extracted.toolCallId,
41
+ }),
31
42
  };
32
43
  });
33
44
  return [...system, ...conversation];
@@ -44,5 +55,13 @@ function parseTools(req: AnthropicRequest): readonly ToolDef[] | undefined {
44
55
 
45
56
  export function parseRequest(body: unknown, meta?: RequestMeta): MockRequest {
46
57
  const req = AnthropicRequestSchema.parse(body);
47
- return buildMockRequest("anthropic", req, parseMessages(req), parseTools(req), "claude-sonnet-4-6", body, meta);
58
+ return buildMockRequest(
59
+ "anthropic",
60
+ req,
61
+ parseMessages(req),
62
+ parseTools(req),
63
+ "claude-sonnet-4-6",
64
+ body,
65
+ meta,
66
+ );
48
67
  }
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod";
2
2
 
3
- export { AnthropicRequestSchema, type AnthropicRequest } from "llm-schemas/anthropic";
3
+ export {
4
+ AnthropicRequestSchema,
5
+ type AnthropicRequest,
6
+ } from "llm-schemas/anthropic";
4
7
 
5
8
  const ResponseContentBlockSchema = z.object({
6
9
  type: z.string(),
@@ -28,18 +31,23 @@ export type AnthropicMessageStart = z.infer<typeof AnthropicMessageStartSchema>;
28
31
  export const AnthropicBlockEventSchema = z.object({
29
32
  index: z.number(),
30
33
  content_block: ResponseContentBlockSchema.optional(),
31
- delta: z.object({
32
- type: z.string(),
33
- text: z.string().optional(),
34
- thinking: z.string().optional(),
35
- partial_json: z.string().optional(),
36
- }).optional(),
34
+ delta: z
35
+ .object({
36
+ type: z.string(),
37
+ text: z.string().optional(),
38
+ thinking: z.string().optional(),
39
+ partial_json: z.string().optional(),
40
+ })
41
+ .optional(),
37
42
  });
38
43
 
39
44
  export type AnthropicBlockEvent = z.infer<typeof AnthropicBlockEventSchema>;
40
45
 
41
46
  export const AnthropicDeltaSchema = z.object({
42
- delta: z.object({ stop_reason: z.string(), stop_sequence: z.string().nullable() }),
47
+ delta: z.object({
48
+ stop_reason: z.string(),
49
+ stop_sequence: z.string().nullable(),
50
+ }),
43
51
  usage: z.object({ output_tokens: z.number() }),
44
52
  });
45
53
 
@@ -1,21 +1,49 @@
1
1
  import type { ReplyObject, ReplyOptions } from "../../types.js";
2
2
  import type { SSEChunk } from "../types.js";
3
- import { splitText, genId, toolId, shouldEmitText, finishReason, DEFAULT_USAGE } from "../serialize-helpers.js";
3
+ import {
4
+ splitText,
5
+ genId,
6
+ toolId,
7
+ shouldEmitText,
8
+ finishReason,
9
+ DEFAULT_USAGE,
10
+ } from "../serialize-helpers.js";
4
11
 
5
12
  function buildUsage(usage: { input: number; output: number }) {
6
13
  return { input_tokens: usage.input, output_tokens: usage.output };
7
14
  }
8
15
 
9
- function contentBlock(index: number, startBlock: unknown, deltas: SSEChunk[]): SSEChunk[] {
16
+ function contentBlock(
17
+ index: number,
18
+ startBlock: unknown,
19
+ deltas: SSEChunk[],
20
+ ): SSEChunk[] {
10
21
  return [
11
- { event: "content_block_start", data: JSON.stringify({ type: "content_block_start", index, content_block: startBlock }) },
22
+ {
23
+ event: "content_block_start",
24
+ data: JSON.stringify({
25
+ type: "content_block_start",
26
+ index,
27
+ content_block: startBlock,
28
+ }),
29
+ },
12
30
  ...deltas,
13
- { event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index }) },
31
+ {
32
+ event: "content_block_stop",
33
+ data: JSON.stringify({ type: "content_block_stop", index }),
34
+ },
14
35
  ];
15
36
  }
16
37
 
17
38
  function delta(index: number, payload: Record<string, unknown>): SSEChunk {
18
- return { event: "content_block_delta", data: JSON.stringify({ type: "content_block_delta", index, delta: payload }) };
39
+ return {
40
+ event: "content_block_delta",
41
+ data: JSON.stringify({
42
+ type: "content_block_delta",
43
+ index,
44
+ delta: payload,
45
+ }),
46
+ };
19
47
  }
20
48
 
21
49
  function reasoningBlock(i: number, reasoning: string): SSEChunk[] {
@@ -28,68 +56,124 @@ function textBlock(i: number, text: string, chunkSize: number): SSEChunk[] {
28
56
  return contentBlock(
29
57
  i,
30
58
  { type: "text", text: "" },
31
- splitText(text, chunkSize).map((piece) => delta(i, { type: "text_delta", text: piece })),
59
+ splitText(text, chunkSize).map((piece) =>
60
+ delta(i, { type: "text_delta", text: piece }),
61
+ ),
32
62
  );
33
63
  }
34
64
 
35
- function toolBlocks(startIndex: number, tools: ReplyObject["tools"]): SSEChunk[] {
65
+ function toolBlocks(
66
+ startIndex: number,
67
+ tools: ReplyObject["tools"],
68
+ ): SSEChunk[] {
36
69
  return (tools ?? []).flatMap((tool, i) => {
37
70
  const idx = startIndex + i;
38
71
  const id = toolId(tool, "toolu", idx);
39
72
  return contentBlock(
40
73
  idx,
41
74
  { type: "tool_use", id, name: tool.name, input: {} },
42
- [delta(idx, { type: "input_json_delta", partial_json: JSON.stringify(tool.args) })],
75
+ [
76
+ delta(idx, {
77
+ type: "input_json_delta",
78
+ partial_json: JSON.stringify(tool.args),
79
+ }),
80
+ ],
43
81
  );
44
82
  });
45
83
  }
46
84
 
47
- export function serialize(reply: ReplyObject, model: string, options: ReplyOptions = {}): readonly SSEChunk[] {
85
+ export function serialize(
86
+ reply: ReplyObject,
87
+ model: string,
88
+ options: ReplyOptions = {},
89
+ ): readonly SSEChunk[] {
48
90
  const id = genId("msg");
49
91
  const usage = reply.usage ?? DEFAULT_USAGE;
50
92
  let idx = 0;
51
93
 
52
- const reasoningChunks = reply.reasoning ? reasoningBlock(idx++, reply.reasoning) : [];
53
- const textChunks = shouldEmitText(reply) ? textBlock(idx++, reply.text ?? "", options.chunkSize ?? 0) : [];
94
+ const reasoningChunks = reply.reasoning
95
+ ? reasoningBlock(idx++, reply.reasoning)
96
+ : [];
97
+ const textChunks = shouldEmitText(reply)
98
+ ? textBlock(idx++, reply.text ?? "", options.chunkSize ?? 0)
99
+ : [];
54
100
  const toolChunks = toolBlocks(idx, reply.tools);
55
101
 
56
102
  return [
57
- { event: "message_start", data: JSON.stringify({
58
- type: "message_start",
59
- message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, usage: { ...buildUsage(usage), output_tokens: 0 } },
60
- })},
103
+ {
104
+ event: "message_start",
105
+ data: JSON.stringify({
106
+ type: "message_start",
107
+ message: {
108
+ id,
109
+ type: "message",
110
+ role: "assistant",
111
+ model,
112
+ content: [],
113
+ stop_reason: null,
114
+ usage: { ...buildUsage(usage), output_tokens: 0 },
115
+ },
116
+ }),
117
+ },
61
118
  ...reasoningChunks,
62
119
  ...textChunks,
63
120
  ...toolChunks,
64
- { event: "message_delta", data: JSON.stringify({
65
- type: "message_delta",
66
- delta: { stop_reason: finishReason(reply, "tool_use", "end_turn"), stop_sequence: null },
67
- usage: { output_tokens: usage.output },
68
- })},
121
+ {
122
+ event: "message_delta",
123
+ data: JSON.stringify({
124
+ type: "message_delta",
125
+ delta: {
126
+ stop_reason: finishReason(reply, "tool_use", "end_turn"),
127
+ stop_sequence: null,
128
+ },
129
+ usage: { output_tokens: usage.output },
130
+ }),
131
+ },
69
132
  { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
70
133
  ];
71
134
  }
72
135
 
73
- export function serializeComplete(reply: ReplyObject, model: string): Record<string, unknown> {
136
+ export function serializeComplete(
137
+ reply: ReplyObject,
138
+ model: string,
139
+ ): Record<string, unknown> {
74
140
  const id = genId("msg");
75
141
  const usage = reply.usage ?? DEFAULT_USAGE;
76
142
 
77
143
  const content: unknown[] = [
78
- ...(reply.reasoning ? [{ type: "thinking", thinking: reply.reasoning }] : []),
79
- ...(shouldEmitText(reply) ? [{ type: "text", text: reply.text ?? "" }] : []),
80
- ...(reply.tools ?? []).map((tool) => ({
81
- type: "tool_use", id: toolId(tool, "toolu", 0), name: tool.name, input: tool.args,
144
+ ...(reply.reasoning
145
+ ? [{ type: "thinking", thinking: reply.reasoning }]
146
+ : []),
147
+ ...(shouldEmitText(reply)
148
+ ? [{ type: "text", text: reply.text ?? "" }]
149
+ : []),
150
+ ...(reply.tools ?? []).map((tool, i) => ({
151
+ type: "tool_use",
152
+ id: toolId(tool, "toolu", i),
153
+ name: tool.name,
154
+ input: tool.args,
82
155
  })),
83
156
  ];
84
157
 
85
158
  return {
86
- id, type: "message", role: "assistant", model, content,
159
+ id,
160
+ type: "message",
161
+ role: "assistant",
162
+ model,
163
+ content,
87
164
  stop_reason: finishReason(reply, "tool_use", "end_turn"),
88
165
  stop_sequence: null,
89
166
  usage: buildUsage(usage),
90
167
  };
91
168
  }
92
169
 
93
- export function serializeError(error: { status: number; message: string; type?: string }): Record<string, unknown> {
94
- return { type: "error", error: { type: error.type ?? "api_error", message: error.message } };
170
+ export function serializeError(error: {
171
+ status: number;
172
+ message: string;
173
+ type?: string;
174
+ }): Record<string, unknown> {
175
+ return {
176
+ type: "error",
177
+ error: { type: error.type ?? "api_error", message: error.message },
178
+ };
95
179
  }
@@ -2,7 +2,9 @@ import type { MockRequest, Message, ToolDef } from "../../types.js";
2
2
  import { buildMockRequest, type RequestMeta } from "../request-helpers.js";
3
3
  import { OpenAIRequestSchema, type OpenAIRequest } from "./schema.js";
4
4
 
5
- function extractContent(content: OpenAIRequest["messages"][number]["content"]): string {
5
+ function extractContent(
6
+ content: OpenAIRequest["messages"][number]["content"],
7
+ ): string {
6
8
  if (content == null) return "";
7
9
  if (typeof content === "string") return content;
8
10
  return content
@@ -30,5 +32,13 @@ function parseTools(req: OpenAIRequest): readonly ToolDef[] | undefined {
30
32
 
31
33
  export function parseRequest(body: unknown, meta?: RequestMeta): MockRequest {
32
34
  const req = OpenAIRequestSchema.parse(body);
33
- return buildMockRequest("openai", req, parseMessages(req), parseTools(req), "gpt-5.4", body, meta);
35
+ return buildMockRequest(
36
+ "openai",
37
+ req,
38
+ parseMessages(req),
39
+ parseTools(req),
40
+ "gpt-5.4",
41
+ body,
42
+ meta,
43
+ );
34
44
  }
@@ -1,6 +1,9 @@
1
1
  import { z } from "zod";
2
2
 
3
- export { OpenAIRequestSchema, type OpenAIRequest } from "llm-schemas/openai/chat-completions";
3
+ export {
4
+ OpenAIRequestSchema,
5
+ type OpenAIRequest,
6
+ } from "llm-schemas/openai/chat-completions";
4
7
 
5
8
  const ToolCallResponseSchema = z.object({
6
9
  id: z.string(),
@@ -12,16 +15,20 @@ const UsageSchema = z.object({
12
15
  prompt_tokens: z.number(),
13
16
  completion_tokens: z.number(),
14
17
  total_tokens: z.number(),
15
- prompt_tokens_details: z.object({
16
- cached_tokens: z.number().optional(),
17
- audio_tokens: z.number().optional(),
18
- }).optional(),
19
- completion_tokens_details: z.object({
20
- reasoning_tokens: z.number().optional(),
21
- audio_tokens: z.number().optional(),
22
- accepted_prediction_tokens: z.number().optional(),
23
- rejected_prediction_tokens: z.number().optional(),
24
- }).optional(),
18
+ prompt_tokens_details: z
19
+ .object({
20
+ cached_tokens: z.number().optional(),
21
+ audio_tokens: z.number().optional(),
22
+ })
23
+ .optional(),
24
+ completion_tokens_details: z
25
+ .object({
26
+ reasoning_tokens: z.number().optional(),
27
+ audio_tokens: z.number().optional(),
28
+ accepted_prediction_tokens: z.number().optional(),
29
+ rejected_prediction_tokens: z.number().optional(),
30
+ })
31
+ .optional(),
25
32
  });
26
33
 
27
34
  export const OpenAIChunkSchema = z.object({
@@ -31,16 +38,20 @@ export const OpenAIChunkSchema = z.object({
31
38
  model: z.string(),
32
39
  system_fingerprint: z.string().nullable().optional(),
33
40
  service_tier: z.string().optional(),
34
- choices: z.array(z.object({
35
- index: z.number(),
36
- delta: z.object({
37
- role: z.string(),
38
- content: z.string(),
39
- tool_calls: z.array(ToolCallResponseSchema),
40
- }).partial(),
41
- logprobs: z.unknown().nullable().optional(),
42
- finish_reason: z.string().nullable(),
43
- })),
41
+ choices: z.array(
42
+ z.object({
43
+ index: z.number(),
44
+ delta: z
45
+ .object({
46
+ role: z.string(),
47
+ content: z.string(),
48
+ tool_calls: z.array(ToolCallResponseSchema),
49
+ })
50
+ .partial(),
51
+ logprobs: z.unknown().nullable().optional(),
52
+ finish_reason: z.string().nullable(),
53
+ }),
54
+ ),
44
55
  usage: UsageSchema.nullable().optional(),
45
56
  });
46
57
 
@@ -53,16 +64,18 @@ export const OpenAICompleteSchema = z.object({
53
64
  model: z.string(),
54
65
  system_fingerprint: z.string().nullable().optional(),
55
66
  service_tier: z.string().optional(),
56
- choices: z.array(z.object({
57
- index: z.number(),
58
- message: z.object({
59
- role: z.string(),
60
- content: z.string().nullable(),
61
- tool_calls: z.array(ToolCallResponseSchema).optional(),
67
+ choices: z.array(
68
+ z.object({
69
+ index: z.number(),
70
+ message: z.object({
71
+ role: z.string(),
72
+ content: z.string().nullable(),
73
+ tool_calls: z.array(ToolCallResponseSchema).optional(),
74
+ }),
75
+ logprobs: z.unknown().nullable().optional(),
76
+ finish_reason: z.string(),
62
77
  }),
63
- logprobs: z.unknown().nullable().optional(),
64
- finish_reason: z.string(),
65
- })),
78
+ ),
66
79
  usage: UsageSchema.optional(),
67
80
  });
68
81