llm-mock-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/test.yml +34 -0
- package/.markdownlint.jsonc +11 -0
- package/.node-version +1 -0
- package/.oxlintrc.json +35 -0
- package/ARCHITECTURE.md +125 -0
- package/LICENCE +21 -0
- package/README.md +448 -0
- package/package.json +55 -0
- package/src/cli-validators.ts +56 -0
- package/src/cli.ts +128 -0
- package/src/formats/anthropic/index.ts +14 -0
- package/src/formats/anthropic/parse.ts +48 -0
- package/src/formats/anthropic/schema.ts +133 -0
- package/src/formats/anthropic/serialize.ts +91 -0
- package/src/formats/openai/index.ts +14 -0
- package/src/formats/openai/parse.ts +34 -0
- package/src/formats/openai/schema.ts +147 -0
- package/src/formats/openai/serialize.ts +92 -0
- package/src/formats/parse-helpers.ts +79 -0
- package/src/formats/responses/index.ts +14 -0
- package/src/formats/responses/parse.ts +56 -0
- package/src/formats/responses/schema.ts +143 -0
- package/src/formats/responses/serialize.ts +129 -0
- package/src/formats/types.ts +17 -0
- package/src/history.ts +66 -0
- package/src/index.ts +44 -0
- package/src/loader.ts +213 -0
- package/src/logger.ts +58 -0
- package/src/mock-server.ts +237 -0
- package/src/route-handler.ts +113 -0
- package/src/rule-engine.ts +119 -0
- package/src/sse-writer.ts +35 -0
- package/src/types/index.ts +4 -0
- package/src/types/reply.ts +49 -0
- package/src/types/request.ts +45 -0
- package/src/types/rule.ts +74 -0
- package/src/types.ts +5 -0
- package/test/cli-validators.test.ts +131 -0
- package/test/formats/anthropic-schema.test.ts +192 -0
- package/test/formats/anthropic.test.ts +260 -0
- package/test/formats/openai-schema.test.ts +105 -0
- package/test/formats/openai.test.ts +243 -0
- package/test/formats/responses-schema.test.ts +114 -0
- package/test/formats/responses.test.ts +299 -0
- package/test/loader.test.ts +314 -0
- package/test/mock-server.test.ts +565 -0
- package/test/rule-engine.test.ts +213 -0
- package/tsconfig.json +26 -0
- package/tsconfig.test.json +11 -0
- package/vitest.config.ts +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "llm-mock-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A standalone mock LLM server for deterministic testing: OpenAI, Anthropic, and Responses API formats",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"llm-mock-server": "./dist/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"lint": "oxlint --tsconfig tsconfig.json --import-plugin --vitest-plugin src/ test/",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json && npm run lint && npm test",
|
|
27
|
+
"dev": "tsx src/cli.ts",
|
|
28
|
+
"start": "node dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"llm",
|
|
32
|
+
"mock",
|
|
33
|
+
"server",
|
|
34
|
+
"openai",
|
|
35
|
+
"anthropic",
|
|
36
|
+
"testing",
|
|
37
|
+
"sse"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"commander": "14.0.3",
|
|
42
|
+
"fastify": "5.8.2",
|
|
43
|
+
"json5": "2.2.3",
|
|
44
|
+
"picocolors": "1.1.1",
|
|
45
|
+
"zod": "4.3.6"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "25.5.0",
|
|
49
|
+
"@vitest/coverage-v8": "4.1.0",
|
|
50
|
+
"oxlint": "1.55.0",
|
|
51
|
+
"tsx": "4.21.0",
|
|
52
|
+
"typescript": "5.9.3",
|
|
53
|
+
"vitest": "4.1.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { LEVEL_PRIORITY, type LogLevel } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const VALID_LOG_LEVELS = Object.keys(LEVEL_PRIORITY) as LogLevel[];
|
|
6
|
+
|
|
7
|
+
function isLogLevel(value: string): value is LogLevel {
|
|
8
|
+
return value in LEVEL_PRIORITY;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_PORT = 65535;
|
|
12
|
+
|
|
13
|
+
export function parsePort(value: string): number {
|
|
14
|
+
const port = parseInt(value, 10);
|
|
15
|
+
if (isNaN(port) || port < 1 || port > MAX_PORT) {
|
|
16
|
+
throw new Error(`Invalid port "${value}". Must be 1-${String(MAX_PORT)}.`);
|
|
17
|
+
}
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseLogLevel(value: string): LogLevel {
|
|
22
|
+
if (!isLogLevel(value)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Invalid log level "${value}". Valid: ${VALID_LOG_LEVELS.join(", ")}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function parseHost(value: string): Promise<string> {
|
|
31
|
+
if (value === "localhost" || isIP(value) !== 0) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await lookup(value);
|
|
36
|
+
return value;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`Invalid host "${value}". Must be a resolvable hostname or IP address.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseChunkSize(value: string): number {
|
|
43
|
+
const size = parseInt(value, 10);
|
|
44
|
+
if (isNaN(size) || size < 0) {
|
|
45
|
+
throw new Error(`Invalid chunk size "${value}". Must be a non-negative integer.`);
|
|
46
|
+
}
|
|
47
|
+
return size;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseLatency(value: string): number {
|
|
51
|
+
const ms = parseInt(value, 10);
|
|
52
|
+
if (isNaN(ms) || ms < 0) {
|
|
53
|
+
throw new Error(`Invalid latency "${value}". Must be a non-negative integer (ms).`);
|
|
54
|
+
}
|
|
55
|
+
return ms;
|
|
56
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { watch } from "node:fs";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import { MockServer } from "./mock-server.js";
|
|
7
|
+
import { Logger } from "./logger.js";
|
|
8
|
+
import { parsePort, parseHost, parseLogLevel, parseChunkSize, parseLatency } from "./cli-validators.js";
|
|
9
|
+
|
|
10
|
+
const WATCH_DEBOUNCE_MS = 100;
|
|
11
|
+
|
|
12
|
+
interface StartOptions {
|
|
13
|
+
port: string;
|
|
14
|
+
host: string;
|
|
15
|
+
rules?: string;
|
|
16
|
+
handler?: string;
|
|
17
|
+
latency: string;
|
|
18
|
+
chunkSize: string;
|
|
19
|
+
fallback?: string;
|
|
20
|
+
logLevel: string;
|
|
21
|
+
watch?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function start(options: StartOptions): Promise<void> {
|
|
25
|
+
const logLevel = parseLogLevel(options.logLevel);
|
|
26
|
+
const logger = new Logger(logLevel);
|
|
27
|
+
const port = parsePort(options.port);
|
|
28
|
+
const host = await parseHost(options.host);
|
|
29
|
+
const latency = parseLatency(options.latency);
|
|
30
|
+
const chunkSize = parseChunkSize(options.chunkSize);
|
|
31
|
+
|
|
32
|
+
const server = new MockServer({
|
|
33
|
+
port,
|
|
34
|
+
host,
|
|
35
|
+
logLevel,
|
|
36
|
+
...(latency > 0 && { defaultLatency: latency }),
|
|
37
|
+
...(chunkSize > 0 && { defaultChunkSize: chunkSize }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (options.fallback) {
|
|
41
|
+
server.fallback(options.fallback);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (options.rules) {
|
|
45
|
+
await server.load(options.rules);
|
|
46
|
+
}
|
|
47
|
+
if (options.handler) {
|
|
48
|
+
await server.load(options.handler);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const quiet = logLevel === "none";
|
|
52
|
+
|
|
53
|
+
await server.start(port);
|
|
54
|
+
|
|
55
|
+
if (!quiet) {
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(` ${pc.bold(pc.cyan("llm-mock-server"))} ${pc.dim("v1.0.0")}`);
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(` ${pc.dim("Port")} ${pc.bold(String(port))}`);
|
|
60
|
+
console.log(` ${pc.dim("Rules")} ${pc.bold(String(server.ruleCount))} loaded`);
|
|
61
|
+
if (latency > 0) {
|
|
62
|
+
console.log(` ${pc.dim("Latency")} ${pc.bold(`${String(latency)}ms`)} per chunk`);
|
|
63
|
+
}
|
|
64
|
+
console.log(
|
|
65
|
+
` ${pc.dim("Endpoints")} ${pc.green("/v1/chat/completions")}, ${pc.green("/v1/messages")}, ${pc.green("/v1/responses")}`,
|
|
66
|
+
);
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (options.watch && options.rules) {
|
|
71
|
+
const rulesPath = options.rules;
|
|
72
|
+
let reloading = false;
|
|
73
|
+
watch(rulesPath, { recursive: true }, () => {
|
|
74
|
+
if (reloading) return;
|
|
75
|
+
reloading = true;
|
|
76
|
+
setTimeout(async () => {
|
|
77
|
+
try {
|
|
78
|
+
server.reset();
|
|
79
|
+
await server.load(rulesPath);
|
|
80
|
+
if (options.fallback) server.fallback(options.fallback);
|
|
81
|
+
logger.info(`Reloaded rules from ${rulesPath}`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logger.error("Failed to reload rules", err);
|
|
84
|
+
}
|
|
85
|
+
reloading = false;
|
|
86
|
+
}, WATCH_DEBOUNCE_MS);
|
|
87
|
+
});
|
|
88
|
+
logger.info(`Watching ${rulesPath} for changes`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let shuttingDown = false;
|
|
92
|
+
const shutdown = async (signal: string) => {
|
|
93
|
+
if (shuttingDown) return;
|
|
94
|
+
shuttingDown = true;
|
|
95
|
+
|
|
96
|
+
logger.info(`Got ${signal}, shutting down...`);
|
|
97
|
+
await server.stop();
|
|
98
|
+
logger.info("Clean shutdown complete");
|
|
99
|
+
process.exit(0);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
process.on("SIGINT", () => { shutdown("SIGINT").catch(() => process.exit(1)); });
|
|
103
|
+
process.on("SIGTERM", () => { shutdown("SIGTERM").catch(() => process.exit(1)); });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const program = new Command()
|
|
107
|
+
.name("llm-mock-server")
|
|
108
|
+
.description("Mock LLM server for deterministic testing")
|
|
109
|
+
.version("1.0.0");
|
|
110
|
+
|
|
111
|
+
program
|
|
112
|
+
.command("start", { isDefault: true })
|
|
113
|
+
.description("Start the mock server")
|
|
114
|
+
.option("-p, --port <number>", "port to listen on", "5555")
|
|
115
|
+
.option("-H, --host <address>", "host to bind to", "127.0.0.1")
|
|
116
|
+
.option("-r, --rules <path>", "path to .json5 rules file or directory")
|
|
117
|
+
.option("--handler <path>", "path to .ts handler file")
|
|
118
|
+
.option("-l, --latency <ms>", "latency between SSE chunks (ms)", "0")
|
|
119
|
+
.option("-c, --chunk-size <chars>", "characters per SSE chunk", "0")
|
|
120
|
+
.option("-f, --fallback <text>", "fallback reply text")
|
|
121
|
+
.option("-w, --watch", "watch rules path and reload on changes")
|
|
122
|
+
.option("--log-level <level>", "log verbosity", "info")
|
|
123
|
+
.action((options: StartOptions) => start(options));
|
|
124
|
+
|
|
125
|
+
program.parseAsync().catch((err: unknown) => {
|
|
126
|
+
console.error("Fatal error:", err);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Format } from "../types.js";
|
|
2
|
+
import { isStreaming } from "../parse-helpers.js";
|
|
3
|
+
import { parseRequest } from "./parse.js";
|
|
4
|
+
import { serialize, serializeComplete, serializeError } from "./serialize.js";
|
|
5
|
+
|
|
6
|
+
export const anthropicFormat: Format = {
|
|
7
|
+
name: "anthropic",
|
|
8
|
+
route: "/v1/messages",
|
|
9
|
+
parseRequest,
|
|
10
|
+
isStreaming,
|
|
11
|
+
serialize,
|
|
12
|
+
serializeComplete,
|
|
13
|
+
serializeError,
|
|
14
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { MockRequest, Message, ToolDef } from "../../types.js";
|
|
2
|
+
import { buildMockRequest, type RequestMeta } from "../parse-helpers.js";
|
|
3
|
+
import { AnthropicRequestSchema, type AnthropicRequest } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
function extractSystem(system: AnthropicRequest["system"]): Message[] {
|
|
6
|
+
if (system == null) return [];
|
|
7
|
+
if (typeof system === "string") return system ? [{ role: "system", content: system }] : [];
|
|
8
|
+
const text = system.map((b) => b.text).join("\n");
|
|
9
|
+
return text ? [{ role: "system", content: text }] : [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function extractContent(content: AnthropicRequest["messages"][number]["content"]): { content: string; toolCallId?: string | undefined } {
|
|
13
|
+
if (typeof content === "string") return { content };
|
|
14
|
+
const text = content
|
|
15
|
+
.filter((b) => b.type === "text")
|
|
16
|
+
.map((b) => b.text)
|
|
17
|
+
.join("\n");
|
|
18
|
+
const toolResult = content.find((b) => b.type === "tool_result");
|
|
19
|
+
const toolCallId = toolResult?.type === "tool_result" ? toolResult.tool_use_id : undefined;
|
|
20
|
+
return { content: text, toolCallId };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseMessages(req: AnthropicRequest): readonly Message[] {
|
|
24
|
+
const system = extractSystem(req.system);
|
|
25
|
+
const conversation = req.messages.map((m) => {
|
|
26
|
+
const extracted = extractContent(m.content);
|
|
27
|
+
return {
|
|
28
|
+
role: m.role,
|
|
29
|
+
content: extracted.content,
|
|
30
|
+
...(extracted.toolCallId !== undefined && { toolCallId: extracted.toolCallId }),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
return [...system, ...conversation];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTools(req: AnthropicRequest): readonly ToolDef[] | undefined {
|
|
37
|
+
if (!req.tools) return undefined;
|
|
38
|
+
return req.tools.map((t) => ({
|
|
39
|
+
name: t.name,
|
|
40
|
+
description: t.description,
|
|
41
|
+
parameters: t.input_schema,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseRequest(body: unknown, meta?: RequestMeta): MockRequest {
|
|
46
|
+
const req = AnthropicRequestSchema.parse(body);
|
|
47
|
+
return buildMockRequest("anthropic", req, parseMessages(req), parseTools(req), "claude-sonnet-4-6", body, meta);
|
|
48
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const TextBlockSchema = z.object({ type: z.literal("text"), text: z.string() });
|
|
4
|
+
|
|
5
|
+
const ToolUseBlockSchema = z.object({
|
|
6
|
+
type: z.literal("tool_use"),
|
|
7
|
+
id: z.string(),
|
|
8
|
+
name: z.string(),
|
|
9
|
+
input: z.record(z.string(), z.unknown()),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const ToolResultBlockSchema = z.object({
|
|
13
|
+
type: z.literal("tool_result"),
|
|
14
|
+
tool_use_id: z.string(),
|
|
15
|
+
content: z.union([z.string(), z.array(TextBlockSchema)]).optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const KnownContentBlockSchema = z.discriminatedUnion("type", [
|
|
19
|
+
TextBlockSchema,
|
|
20
|
+
ToolUseBlockSchema,
|
|
21
|
+
ToolResultBlockSchema,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const LooseContentBlockSchema = z.union([
|
|
25
|
+
KnownContentBlockSchema,
|
|
26
|
+
z.looseObject({ type: z.string() }),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const KNOWN_BLOCK_TYPES = new Set(["text", "tool_use", "tool_result"]);
|
|
30
|
+
|
|
31
|
+
type KnownBlock = z.infer<typeof KnownContentBlockSchema>;
|
|
32
|
+
|
|
33
|
+
const MessageSchema = z.object({
|
|
34
|
+
role: z.enum(["user", "assistant"]),
|
|
35
|
+
content: z.union([
|
|
36
|
+
z.string(),
|
|
37
|
+
z.array(LooseContentBlockSchema).transform((blocks) =>
|
|
38
|
+
blocks.filter((b): b is KnownBlock => KNOWN_BLOCK_TYPES.has(b.type)),
|
|
39
|
+
),
|
|
40
|
+
]),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const ToolDefinitionSchema = z.object({
|
|
44
|
+
name: z.string(),
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
input_schema: z.record(z.string(), z.unknown()),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const AnthropicRequestSchema = z.looseObject({
|
|
50
|
+
model: z.string().min(1),
|
|
51
|
+
max_tokens: z.number().int().positive(),
|
|
52
|
+
system: z.union([z.string(), z.array(TextBlockSchema)]).optional(),
|
|
53
|
+
messages: z.array(MessageSchema).min(1),
|
|
54
|
+
tools: z.array(ToolDefinitionSchema).optional(),
|
|
55
|
+
stream: z.boolean().optional(),
|
|
56
|
+
temperature: z.number().optional(),
|
|
57
|
+
top_p: z.number().optional(),
|
|
58
|
+
top_k: z.number().optional(),
|
|
59
|
+
stop_sequences: z.array(z.string()).optional(),
|
|
60
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
61
|
+
cache_control: z.unknown().optional(),
|
|
62
|
+
container: z.string().optional(),
|
|
63
|
+
inference_geo: z.string().optional(),
|
|
64
|
+
output_config: z.unknown().optional(),
|
|
65
|
+
service_tier: z.string().optional(),
|
|
66
|
+
thinking: z.unknown().optional(),
|
|
67
|
+
tool_choice: z.unknown().optional(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export type AnthropicRequest = z.infer<typeof AnthropicRequestSchema>;
|
|
71
|
+
|
|
72
|
+
const ResponseContentBlockSchema = z.object({
|
|
73
|
+
type: z.string(),
|
|
74
|
+
text: z.string().optional(),
|
|
75
|
+
thinking: z.string().optional(),
|
|
76
|
+
id: z.string().optional(),
|
|
77
|
+
name: z.string().optional(),
|
|
78
|
+
input: z.unknown().optional(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const AnthropicMessageStartSchema = z.object({
|
|
82
|
+
message: z.object({
|
|
83
|
+
id: z.string(),
|
|
84
|
+
type: z.literal("message"),
|
|
85
|
+
role: z.literal("assistant"),
|
|
86
|
+
content: z.array(z.unknown()),
|
|
87
|
+
model: z.string(),
|
|
88
|
+
stop_reason: z.string().nullable(),
|
|
89
|
+
usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export type AnthropicMessageStart = z.infer<typeof AnthropicMessageStartSchema>;
|
|
94
|
+
|
|
95
|
+
export const AnthropicBlockEventSchema = z.object({
|
|
96
|
+
index: z.number(),
|
|
97
|
+
content_block: ResponseContentBlockSchema.optional(),
|
|
98
|
+
delta: z.object({
|
|
99
|
+
type: z.string(),
|
|
100
|
+
text: z.string().optional(),
|
|
101
|
+
thinking: z.string().optional(),
|
|
102
|
+
partial_json: z.string().optional(),
|
|
103
|
+
}).optional(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export type AnthropicBlockEvent = z.infer<typeof AnthropicBlockEventSchema>;
|
|
107
|
+
|
|
108
|
+
export const AnthropicDeltaSchema = z.object({
|
|
109
|
+
delta: z.object({ stop_reason: z.string(), stop_sequence: z.string().nullable() }),
|
|
110
|
+
usage: z.object({ output_tokens: z.number() }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export type AnthropicDelta = z.infer<typeof AnthropicDeltaSchema>;
|
|
114
|
+
|
|
115
|
+
export const AnthropicCompleteSchema = z.object({
|
|
116
|
+
id: z.string(),
|
|
117
|
+
type: z.literal("message"),
|
|
118
|
+
role: z.literal("assistant"),
|
|
119
|
+
model: z.string(),
|
|
120
|
+
content: z.array(ResponseContentBlockSchema),
|
|
121
|
+
stop_reason: z.string(),
|
|
122
|
+
stop_sequence: z.string().nullable(),
|
|
123
|
+
usage: z.object({ input_tokens: z.number(), output_tokens: z.number() }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export type AnthropicComplete = z.infer<typeof AnthropicCompleteSchema>;
|
|
127
|
+
|
|
128
|
+
export const AnthropicErrorSchema = z.object({
|
|
129
|
+
type: z.literal("error"),
|
|
130
|
+
error: z.object({ type: z.string(), message: z.string() }),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type AnthropicError = z.infer<typeof AnthropicErrorSchema>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ReplyObject, ReplyOptions } from "../../types.js";
|
|
2
|
+
import type { SSEChunk } from "../types.js";
|
|
3
|
+
import { splitText, genId, toolId, shouldEmitText, finishReason, DEFAULT_USAGE } from "../parse-helpers.js";
|
|
4
|
+
|
|
5
|
+
function contentBlock(index: number, startBlock: unknown, deltas: SSEChunk[]): SSEChunk[] {
|
|
6
|
+
return [
|
|
7
|
+
{ event: "content_block_start", data: JSON.stringify({ type: "content_block_start", index, content_block: startBlock }) },
|
|
8
|
+
...deltas,
|
|
9
|
+
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index }) },
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function delta(index: number, payload: Record<string, unknown>): SSEChunk {
|
|
14
|
+
return { event: "content_block_delta", data: JSON.stringify({ type: "content_block_delta", index, delta: payload }) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function reasoningBlock(i: number, reasoning: string): SSEChunk[] {
|
|
18
|
+
return contentBlock(i, { type: "thinking", thinking: "" }, [
|
|
19
|
+
delta(i, { type: "thinking_delta", thinking: reasoning }),
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function textBlock(i: number, text: string, chunkSize: number): SSEChunk[] {
|
|
24
|
+
return contentBlock(
|
|
25
|
+
i,
|
|
26
|
+
{ type: "text", text: "" },
|
|
27
|
+
splitText(text, chunkSize).map((piece) => delta(i, { type: "text_delta", text: piece })),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toolBlocks(startIndex: number, tools: ReplyObject["tools"]): SSEChunk[] {
|
|
32
|
+
return (tools ?? []).flatMap((tool, i) => {
|
|
33
|
+
const idx = startIndex + i;
|
|
34
|
+
const id = toolId(tool, "toolu", idx);
|
|
35
|
+
return contentBlock(
|
|
36
|
+
idx,
|
|
37
|
+
{ type: "tool_use", id, name: tool.name, input: {} },
|
|
38
|
+
[delta(idx, { type: "input_json_delta", partial_json: JSON.stringify(tool.args) })],
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function serialize(reply: ReplyObject, model: string, options: ReplyOptions = {}): readonly SSEChunk[] {
|
|
44
|
+
const id = genId("msg");
|
|
45
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
46
|
+
let idx = 0;
|
|
47
|
+
|
|
48
|
+
const reasoningChunks = reply.reasoning ? reasoningBlock(idx++, reply.reasoning) : [];
|
|
49
|
+
const textChunks = shouldEmitText(reply) ? textBlock(idx++, reply.text ?? "", options.chunkSize ?? 0) : [];
|
|
50
|
+
const toolChunks = toolBlocks(idx, reply.tools);
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
{ event: "message_start", data: JSON.stringify({
|
|
54
|
+
type: "message_start",
|
|
55
|
+
message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, usage: { input_tokens: usage.input, output_tokens: 0 } },
|
|
56
|
+
})},
|
|
57
|
+
...reasoningChunks,
|
|
58
|
+
...textChunks,
|
|
59
|
+
...toolChunks,
|
|
60
|
+
{ event: "message_delta", data: JSON.stringify({
|
|
61
|
+
type: "message_delta",
|
|
62
|
+
delta: { stop_reason: finishReason(reply, "tool_use", "end_turn"), stop_sequence: null },
|
|
63
|
+
usage: { output_tokens: usage.output },
|
|
64
|
+
})},
|
|
65
|
+
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function serializeComplete(reply: ReplyObject, model: string): unknown {
|
|
70
|
+
const id = genId("msg");
|
|
71
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
72
|
+
|
|
73
|
+
const content: unknown[] = [
|
|
74
|
+
...(reply.reasoning ? [{ type: "thinking", thinking: reply.reasoning }] : []),
|
|
75
|
+
...(shouldEmitText(reply) ? [{ type: "text", text: reply.text ?? "" }] : []),
|
|
76
|
+
...(reply.tools ?? []).map((tool) => ({
|
|
77
|
+
type: "tool_use", id: toolId(tool, "toolu", 0), name: tool.name, input: tool.args,
|
|
78
|
+
})),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id, type: "message", role: "assistant", model, content,
|
|
83
|
+
stop_reason: finishReason(reply, "tool_use", "end_turn"),
|
|
84
|
+
stop_sequence: null,
|
|
85
|
+
usage: { input_tokens: usage.input, output_tokens: usage.output },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function serializeError(error: { status: number; message: string; type?: string }): unknown {
|
|
90
|
+
return { type: "error", error: { type: error.type ?? "api_error", message: error.message } };
|
|
91
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Format } from "../types.js";
|
|
2
|
+
import { isStreaming } from "../parse-helpers.js";
|
|
3
|
+
import { parseRequest } from "./parse.js";
|
|
4
|
+
import { serialize, serializeComplete, serializeError } from "./serialize.js";
|
|
5
|
+
|
|
6
|
+
export const openaiFormat: Format = {
|
|
7
|
+
name: "openai",
|
|
8
|
+
route: "/v1/chat/completions",
|
|
9
|
+
parseRequest,
|
|
10
|
+
isStreaming,
|
|
11
|
+
serialize,
|
|
12
|
+
serializeComplete,
|
|
13
|
+
serializeError,
|
|
14
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MockRequest, Message, ToolDef } from "../../types.js";
|
|
2
|
+
import { buildMockRequest, type RequestMeta } from "../parse-helpers.js";
|
|
3
|
+
import { OpenAIRequestSchema, type OpenAIRequest } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
function extractContent(content: OpenAIRequest["messages"][number]["content"]): string {
|
|
6
|
+
if (content == null) return "";
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
return content
|
|
9
|
+
.filter((p) => p.type === "text" && p.text !== undefined)
|
|
10
|
+
.map((p) => p.text!)
|
|
11
|
+
.join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseMessages(req: OpenAIRequest): readonly Message[] {
|
|
15
|
+
return req.messages.map((m) => ({
|
|
16
|
+
role: m.role === "developer" ? "system" : (m.role ?? "user"),
|
|
17
|
+
content: extractContent(m.content),
|
|
18
|
+
...(m.tool_call_id !== undefined && { toolCallId: m.tool_call_id }),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseTools(req: OpenAIRequest): readonly ToolDef[] | undefined {
|
|
23
|
+
if (!req.tools) return undefined;
|
|
24
|
+
return req.tools.map((t) => ({
|
|
25
|
+
name: t.function.name,
|
|
26
|
+
description: t.function.description,
|
|
27
|
+
parameters: t.function.parameters,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseRequest(body: unknown, meta?: RequestMeta): MockRequest {
|
|
32
|
+
const req = OpenAIRequestSchema.parse(body);
|
|
33
|
+
return buildMockRequest("openai", req, parseMessages(req), parseTools(req), "gpt-5.4", body, meta);
|
|
34
|
+
}
|