Skip to content

Commit 7a8c3d7

Browse files
committed
Add core source modules
Types, helpers, router, journal, SSE writer, fixture loader, HTTP server, MockOpenAI facade, CLI entry point, and public API.
1 parent 631ab02 commit 7a8c3d7

10 files changed

Lines changed: 1006 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env node
2+
import { parseArgs } from "node:util";
3+
import { statSync } from "node:fs";
4+
import { resolve } from "node:path";
5+
import { createServer } from "./server.js";
6+
import { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js";
7+
8+
const HELP = `
9+
Usage: mock-openai [options]
10+
11+
Options:
12+
-p, --port <number> Port to listen on (default: 4010)
13+
-h, --host <string> Host to bind to (default: 127.0.0.1)
14+
-f, --fixtures <path> Path to fixtures directory or file (default: ./fixtures)
15+
-l, --latency <ms> Latency in ms between SSE chunks (default: 0)
16+
-c, --chunk-size <chars> Chunk size in characters (default: 20)
17+
--help Show this help message
18+
`.trim();
19+
20+
const { values } = parseArgs({
21+
options: {
22+
port: { type: "string", short: "p", default: "4010" },
23+
host: { type: "string", short: "h", default: "127.0.0.1" },
24+
fixtures: { type: "string", short: "f", default: "./fixtures" },
25+
latency: { type: "string", short: "l", default: "0" },
26+
"chunk-size": { type: "string", short: "c", default: "20" },
27+
help: { type: "boolean", default: false },
28+
},
29+
strict: true,
30+
});
31+
32+
if (values.help) {
33+
console.log(HELP);
34+
process.exit(0);
35+
}
36+
37+
const port = Number(values.port);
38+
const host = values.host!;
39+
const latency = Number(values.latency);
40+
const chunkSize = Number(values["chunk-size"]);
41+
const fixturePath = resolve(values.fixtures!);
42+
43+
if (Number.isNaN(port) || port < 0 || port > 65535) {
44+
console.error(`Invalid port: ${values.port}`);
45+
process.exit(1);
46+
}
47+
48+
if (Number.isNaN(latency) || latency < 0) {
49+
console.error(`Invalid latency: ${values.latency}`);
50+
process.exit(1);
51+
}
52+
53+
if (Number.isNaN(chunkSize) || chunkSize < 1) {
54+
console.error(`Invalid chunk-size: ${values["chunk-size"]}`);
55+
process.exit(1);
56+
}
57+
58+
async function main() {
59+
// Load fixtures from path (detect file vs directory)
60+
let fixtures;
61+
try {
62+
const stat = statSync(fixturePath);
63+
if (stat.isDirectory()) {
64+
fixtures = loadFixturesFromDir(fixturePath);
65+
} else {
66+
fixtures = loadFixtureFile(fixturePath);
67+
}
68+
} catch {
69+
console.error(`Fixtures path not found: ${fixturePath}`);
70+
process.exit(1);
71+
}
72+
73+
console.log(`Loaded ${fixtures.length} fixture(s) from ${fixturePath}`);
74+
75+
const instance = await createServer(fixtures, {
76+
port,
77+
host,
78+
latency,
79+
chunkSize,
80+
});
81+
82+
console.log(`Mock OpenAI server listening on ${instance.url}`);
83+
84+
function shutdown() {
85+
console.log("\nShutting down...");
86+
instance.server.close(() => {
87+
process.exit(0);
88+
});
89+
}
90+
91+
process.on("SIGINT", shutdown);
92+
process.on("SIGTERM", shutdown);
93+
}
94+
95+
main().catch((err) => {
96+
console.error(err);
97+
process.exit(1);
98+
});

src/fixture-loader.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { readFileSync, readdirSync, statSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { Fixture, FixtureFile, FixtureFileEntry } from "./types.js";
4+
5+
function entryToFixture(entry: FixtureFileEntry): Fixture {
6+
return {
7+
match: {
8+
userMessage: entry.match.userMessage,
9+
toolCallId: entry.match.toolCallId,
10+
toolName: entry.match.toolName,
11+
model: entry.match.model,
12+
},
13+
response: entry.response,
14+
...(entry.latency !== undefined && { latency: entry.latency }),
15+
...(entry.chunkSize !== undefined && { chunkSize: entry.chunkSize }),
16+
};
17+
}
18+
19+
export function loadFixtureFile(filePath: string): Fixture[] {
20+
let raw: string;
21+
try {
22+
raw = readFileSync(filePath, "utf-8");
23+
} catch (err) {
24+
console.warn(`[fixture-loader] Could not read file ${filePath}:`, err);
25+
return [];
26+
}
27+
28+
let parsed: unknown;
29+
try {
30+
parsed = JSON.parse(raw);
31+
} catch (err) {
32+
console.warn(`[fixture-loader] Invalid JSON in ${filePath}:`, err);
33+
return [];
34+
}
35+
36+
if (
37+
typeof parsed !== "object" ||
38+
parsed === null ||
39+
!Array.isArray((parsed as FixtureFile).fixtures)
40+
) {
41+
console.warn(`[fixture-loader] Missing or invalid "fixtures" array in ${filePath}`);
42+
return [];
43+
}
44+
45+
return (parsed as FixtureFile).fixtures.map(entryToFixture);
46+
}
47+
48+
export function loadFixturesFromDir(dirPath: string): Fixture[] {
49+
let entries: string[];
50+
try {
51+
entries = readdirSync(dirPath);
52+
} catch (err) {
53+
console.warn(`[fixture-loader] Could not read directory ${dirPath}:`, err);
54+
return [];
55+
}
56+
57+
const jsonFiles: string[] = [];
58+
for (const name of entries) {
59+
const fullPath = join(dirPath, name);
60+
try {
61+
if (statSync(fullPath).isDirectory()) {
62+
console.warn(
63+
`[fixture-loader] Skipping subdirectory ${fullPath} (fixtures are not loaded recursively)`,
64+
);
65+
continue;
66+
}
67+
} catch (err) {
68+
const code = (err as NodeJS.ErrnoException).code;
69+
if (code !== "ENOENT") {
70+
console.warn(`[fixture-loader] Could not stat ${fullPath}:`, err);
71+
}
72+
continue;
73+
}
74+
if (name.endsWith(".json")) {
75+
jsonFiles.push(name);
76+
}
77+
}
78+
jsonFiles.sort();
79+
80+
const fixtures: Fixture[] = [];
81+
for (const name of jsonFiles) {
82+
const filePath = join(dirPath, name);
83+
fixtures.push(...loadFixtureFile(filePath));
84+
}
85+
86+
return fixtures;
87+
}

src/helpers.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { randomBytes } from "node:crypto";
2+
import type {
3+
FixtureResponse,
4+
TextResponse,
5+
ToolCallResponse,
6+
ErrorResponse,
7+
SSEChunk,
8+
ToolCall,
9+
} from "./types.js";
10+
11+
export function generateId(prefix = "chatcmpl"): string {
12+
return `${prefix}-${randomBytes(12).toString("base64url")}`;
13+
}
14+
15+
export function generateToolCallId(): string {
16+
return `call_${randomBytes(12).toString("base64url")}`;
17+
}
18+
19+
export function isTextResponse(r: FixtureResponse): r is TextResponse {
20+
return "content" in r && typeof (r as TextResponse).content === "string";
21+
}
22+
23+
export function isToolCallResponse(r: FixtureResponse): r is ToolCallResponse {
24+
return "toolCalls" in r && Array.isArray((r as ToolCallResponse).toolCalls);
25+
}
26+
27+
export function isErrorResponse(r: FixtureResponse): r is ErrorResponse {
28+
return (
29+
"error" in r &&
30+
(r as ErrorResponse).error !== null &&
31+
typeof (r as ErrorResponse).error === "object"
32+
);
33+
}
34+
35+
export function buildTextChunks(content: string, model: string, chunkSize: number): SSEChunk[] {
36+
const id = generateId();
37+
const created = Math.floor(Date.now() / 1000);
38+
const chunks: SSEChunk[] = [];
39+
40+
// Role chunk
41+
chunks.push({
42+
id,
43+
object: "chat.completion.chunk",
44+
created,
45+
model,
46+
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
47+
});
48+
49+
// Content chunks
50+
for (let i = 0; i < content.length; i += chunkSize) {
51+
const slice = content.slice(i, i + chunkSize);
52+
chunks.push({
53+
id,
54+
object: "chat.completion.chunk",
55+
created,
56+
model,
57+
choices: [{ index: 0, delta: { content: slice }, finish_reason: null }],
58+
});
59+
}
60+
61+
// Finish chunk
62+
chunks.push({
63+
id,
64+
object: "chat.completion.chunk",
65+
created,
66+
model,
67+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
68+
});
69+
70+
return chunks;
71+
}
72+
73+
export function buildToolCallChunks(
74+
toolCalls: ToolCall[],
75+
model: string,
76+
chunkSize: number,
77+
): SSEChunk[] {
78+
const id = generateId();
79+
const created = Math.floor(Date.now() / 1000);
80+
const chunks: SSEChunk[] = [];
81+
82+
// Role chunk
83+
chunks.push({
84+
id,
85+
object: "chat.completion.chunk",
86+
created,
87+
model,
88+
choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }],
89+
});
90+
91+
// Tool call chunks — one initial chunk per tool call, then argument chunks
92+
for (let tcIdx = 0; tcIdx < toolCalls.length; tcIdx++) {
93+
const tc = toolCalls[tcIdx];
94+
const tcId = tc.id || generateToolCallId();
95+
96+
// Initial tool call chunk (id + function name)
97+
chunks.push({
98+
id,
99+
object: "chat.completion.chunk",
100+
created,
101+
model,
102+
choices: [
103+
{
104+
index: 0,
105+
delta: {
106+
tool_calls: [
107+
{
108+
index: tcIdx,
109+
id: tcId,
110+
type: "function",
111+
function: { name: tc.name, arguments: "" },
112+
},
113+
],
114+
},
115+
finish_reason: null,
116+
},
117+
],
118+
});
119+
120+
// Argument streaming chunks
121+
const args = tc.arguments;
122+
for (let i = 0; i < args.length; i += chunkSize) {
123+
const slice = args.slice(i, i + chunkSize);
124+
chunks.push({
125+
id,
126+
object: "chat.completion.chunk",
127+
created,
128+
model,
129+
choices: [
130+
{
131+
index: 0,
132+
delta: {
133+
tool_calls: [{ index: tcIdx, function: { arguments: slice } }],
134+
},
135+
finish_reason: null,
136+
},
137+
],
138+
});
139+
}
140+
}
141+
142+
// Finish chunk
143+
chunks.push({
144+
id,
145+
object: "chat.completion.chunk",
146+
created,
147+
model,
148+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
149+
});
150+
151+
return chunks;
152+
}

src/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Main class
2+
export { MockOpenAI } from "./mock-openai.js";
3+
4+
// Server
5+
export { createServer, type ServerInstance } from "./server.js";
6+
7+
// Fixture loading
8+
export { loadFixtureFile, loadFixturesFromDir } from "./fixture-loader.js";
9+
10+
// Journal
11+
export { Journal } from "./journal.js";
12+
13+
// Router
14+
export { matchFixture } from "./router.js";
15+
16+
// Helpers
17+
export { generateId, generateToolCallId, buildTextChunks, buildToolCallChunks } from "./helpers.js";
18+
19+
// SSE
20+
export { writeSSEStream, writeErrorResponse } from "./sse-writer.js";
21+
22+
// Types
23+
export type {
24+
ChatMessage,
25+
ChatCompletionRequest,
26+
ToolDefinition,
27+
FixtureMatch,
28+
TextResponse,
29+
ToolCall,
30+
ToolCallResponse,
31+
ErrorResponse,
32+
FixtureResponse,
33+
Fixture,
34+
FixtureFile,
35+
FixtureFileEntry,
36+
JournalEntry,
37+
SSEChunk,
38+
SSEChoice,
39+
SSEDelta,
40+
SSEToolCallDelta,
41+
MockServerOptions,
42+
ToolCallMessage,
43+
} from "./types.js";

0 commit comments

Comments
 (0)