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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const ContentPartSchema = z.looseObject({
|
|
4
|
+
type: z.string(),
|
|
5
|
+
text: z.string().optional(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const MessageSchema = z.object({
|
|
9
|
+
role: z.enum(["system", "developer", "user", "assistant", "tool"]).optional(),
|
|
10
|
+
content: z.union([z.string(), z.array(ContentPartSchema), z.null()]).optional(),
|
|
11
|
+
name: z.string().optional(),
|
|
12
|
+
tool_calls: z.array(z.object({
|
|
13
|
+
index: z.number().optional(),
|
|
14
|
+
id: z.string().optional(),
|
|
15
|
+
type: z.string().optional(),
|
|
16
|
+
function: z.object({
|
|
17
|
+
name: z.string(),
|
|
18
|
+
arguments: z.string(),
|
|
19
|
+
}),
|
|
20
|
+
})).optional(),
|
|
21
|
+
tool_call_id: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ToolSchema = z.object({
|
|
25
|
+
type: z.string(),
|
|
26
|
+
function: z.object({
|
|
27
|
+
name: z.string(),
|
|
28
|
+
description: z.string().optional(),
|
|
29
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const OpenAIRequestSchema = z.looseObject({
|
|
34
|
+
model: z.string().min(1),
|
|
35
|
+
messages: z.array(MessageSchema).min(1),
|
|
36
|
+
temperature: z.number().optional(),
|
|
37
|
+
top_p: z.number().optional(),
|
|
38
|
+
n: z.number().optional(),
|
|
39
|
+
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
40
|
+
max_tokens: z.number().optional(),
|
|
41
|
+
presence_penalty: z.number().optional(),
|
|
42
|
+
frequency_penalty: z.number().optional(),
|
|
43
|
+
tools: z.array(ToolSchema).optional(),
|
|
44
|
+
stream: z.boolean().optional(),
|
|
45
|
+
tool_choice: z.unknown().optional(),
|
|
46
|
+
user: z.string().optional(),
|
|
47
|
+
audio: z.unknown().optional(),
|
|
48
|
+
function_call: z.unknown().optional(),
|
|
49
|
+
functions: z.array(z.unknown()).optional(),
|
|
50
|
+
logit_bias: z.record(z.string(), z.number()).optional(),
|
|
51
|
+
logprobs: z.boolean().optional(),
|
|
52
|
+
max_completion_tokens: z.number().optional(),
|
|
53
|
+
metadata: z.record(z.string(), z.string()).optional(),
|
|
54
|
+
modalities: z.array(z.string()).optional(),
|
|
55
|
+
parallel_tool_calls: z.boolean().optional(),
|
|
56
|
+
prediction: z.unknown().optional(),
|
|
57
|
+
prompt_cache_key: z.string().optional(),
|
|
58
|
+
prompt_cache_retention: z.string().optional(),
|
|
59
|
+
reasoning_effort: z.string().optional(),
|
|
60
|
+
response_format: z.unknown().optional(),
|
|
61
|
+
safety_identifier: z.string().optional(),
|
|
62
|
+
seed: z.number().optional(),
|
|
63
|
+
service_tier: z.string().optional(),
|
|
64
|
+
store: z.boolean().optional(),
|
|
65
|
+
stream_options: z.unknown().optional(),
|
|
66
|
+
top_logprobs: z.number().optional(),
|
|
67
|
+
verbosity: z.string().optional(),
|
|
68
|
+
web_search_options: z.unknown().optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export type OpenAIRequest = z.infer<typeof OpenAIRequestSchema>;
|
|
72
|
+
|
|
73
|
+
const ToolCallResponseSchema = z.object({
|
|
74
|
+
id: z.string(),
|
|
75
|
+
type: z.string(),
|
|
76
|
+
function: z.object({ name: z.string(), arguments: z.string() }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const UsageSchema = z.object({
|
|
80
|
+
prompt_tokens: z.number(),
|
|
81
|
+
completion_tokens: z.number(),
|
|
82
|
+
total_tokens: z.number(),
|
|
83
|
+
prompt_tokens_details: z.object({
|
|
84
|
+
cached_tokens: z.number().optional(),
|
|
85
|
+
audio_tokens: z.number().optional(),
|
|
86
|
+
}).optional(),
|
|
87
|
+
completion_tokens_details: z.object({
|
|
88
|
+
reasoning_tokens: z.number().optional(),
|
|
89
|
+
audio_tokens: z.number().optional(),
|
|
90
|
+
accepted_prediction_tokens: z.number().optional(),
|
|
91
|
+
rejected_prediction_tokens: z.number().optional(),
|
|
92
|
+
}).optional(),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const OpenAIChunkSchema = z.object({
|
|
96
|
+
id: z.string(),
|
|
97
|
+
object: z.literal("chat.completion.chunk"),
|
|
98
|
+
created: z.number(),
|
|
99
|
+
model: z.string(),
|
|
100
|
+
system_fingerprint: z.string().nullable().optional(),
|
|
101
|
+
service_tier: z.string().optional(),
|
|
102
|
+
choices: z.array(z.object({
|
|
103
|
+
index: z.number(),
|
|
104
|
+
delta: z.object({
|
|
105
|
+
role: z.string(),
|
|
106
|
+
content: z.string(),
|
|
107
|
+
tool_calls: z.array(ToolCallResponseSchema),
|
|
108
|
+
}).partial(),
|
|
109
|
+
logprobs: z.unknown().nullable().optional(),
|
|
110
|
+
finish_reason: z.string().nullable(),
|
|
111
|
+
})),
|
|
112
|
+
usage: UsageSchema.nullable().optional(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export type OpenAIChunk = z.infer<typeof OpenAIChunkSchema>;
|
|
116
|
+
|
|
117
|
+
export const OpenAICompleteSchema = z.object({
|
|
118
|
+
id: z.string(),
|
|
119
|
+
object: z.literal("chat.completion"),
|
|
120
|
+
created: z.number(),
|
|
121
|
+
model: z.string(),
|
|
122
|
+
system_fingerprint: z.string().nullable().optional(),
|
|
123
|
+
service_tier: z.string().optional(),
|
|
124
|
+
choices: z.array(z.object({
|
|
125
|
+
index: z.number(),
|
|
126
|
+
message: z.object({
|
|
127
|
+
role: z.string(),
|
|
128
|
+
content: z.string().nullable(),
|
|
129
|
+
tool_calls: z.array(ToolCallResponseSchema).optional(),
|
|
130
|
+
}),
|
|
131
|
+
logprobs: z.unknown().nullable().optional(),
|
|
132
|
+
finish_reason: z.string(),
|
|
133
|
+
})),
|
|
134
|
+
usage: UsageSchema.optional(),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export type OpenAIComplete = z.infer<typeof OpenAICompleteSchema>;
|
|
138
|
+
|
|
139
|
+
export const OpenAIErrorSchema = z.object({
|
|
140
|
+
error: z.object({
|
|
141
|
+
message: z.string(),
|
|
142
|
+
type: z.string(),
|
|
143
|
+
code: z.string().nullable(),
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export type OpenAIError = z.infer<typeof OpenAIErrorSchema>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ReplyObject, ReplyOptions } from "../../types.js";
|
|
2
|
+
import type { SSEChunk } from "../types.js";
|
|
3
|
+
import { splitText, genId, toolId, finishReason, MS_PER_SECOND, DEFAULT_USAGE } from "../parse-helpers.js";
|
|
4
|
+
|
|
5
|
+
function chunkEnvelope(
|
|
6
|
+
id: string, created: number, model: string,
|
|
7
|
+
delta: Record<string, unknown>, finish_reason: string | null = null,
|
|
8
|
+
usage: Record<string, unknown> | null = null,
|
|
9
|
+
): SSEChunk {
|
|
10
|
+
return {
|
|
11
|
+
data: JSON.stringify({
|
|
12
|
+
id, object: "chat.completion.chunk", created, model,
|
|
13
|
+
system_fingerprint: null,
|
|
14
|
+
service_tier: "default",
|
|
15
|
+
choices: [{ index: 0, delta, logprobs: null, finish_reason }],
|
|
16
|
+
usage,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function serialize(reply: ReplyObject, model: string, options: ReplyOptions = {}): readonly SSEChunk[] {
|
|
22
|
+
const id = genId("chatcmpl");
|
|
23
|
+
const created = Math.floor(Date.now() / MS_PER_SECOND);
|
|
24
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
25
|
+
|
|
26
|
+
const textChunks = reply.text
|
|
27
|
+
? splitText(reply.text, options.chunkSize ?? 0).map((piece) =>
|
|
28
|
+
chunkEnvelope(id, created, model, { content: piece }),
|
|
29
|
+
)
|
|
30
|
+
: [];
|
|
31
|
+
|
|
32
|
+
const toolChunks = (reply.tools ?? []).map((tool, i) =>
|
|
33
|
+
chunkEnvelope(id, created, model, {
|
|
34
|
+
tool_calls: [{
|
|
35
|
+
index: i, id: toolId(tool, "call", i), type: "function",
|
|
36
|
+
function: { name: tool.name, arguments: JSON.stringify(tool.args) },
|
|
37
|
+
}],
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const usageChunk = {
|
|
42
|
+
prompt_tokens: usage.input,
|
|
43
|
+
completion_tokens: usage.output,
|
|
44
|
+
total_tokens: usage.input + usage.output,
|
|
45
|
+
prompt_tokens_details: { cached_tokens: 0, audio_tokens: 0 },
|
|
46
|
+
completion_tokens_details: { reasoning_tokens: 0, audio_tokens: 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0 },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
chunkEnvelope(id, created, model, { role: "assistant" }),
|
|
51
|
+
...textChunks,
|
|
52
|
+
...toolChunks,
|
|
53
|
+
chunkEnvelope(id, created, model, {}, finishReason(reply, "tool_calls", "stop")),
|
|
54
|
+
chunkEnvelope(id, created, model, {}, null, usageChunk),
|
|
55
|
+
{ data: "[DONE]" },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function serializeComplete(reply: ReplyObject, model: string): unknown {
|
|
60
|
+
const id = genId("chatcmpl");
|
|
61
|
+
const created = Math.floor(Date.now() / MS_PER_SECOND);
|
|
62
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
63
|
+
|
|
64
|
+
const message: Record<string, unknown> = {
|
|
65
|
+
role: "assistant",
|
|
66
|
+
content: reply.text ?? null,
|
|
67
|
+
...(reply.tools?.length && {
|
|
68
|
+
tool_calls: reply.tools.map((tool, i) => ({
|
|
69
|
+
id: toolId(tool, "call", i), type: "function",
|
|
70
|
+
function: { name: tool.name, arguments: JSON.stringify(tool.args) },
|
|
71
|
+
})),
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
id, object: "chat.completion", created, model,
|
|
77
|
+
system_fingerprint: null,
|
|
78
|
+
service_tier: "default",
|
|
79
|
+
choices: [{ index: 0, message, logprobs: null, finish_reason: finishReason(reply, "tool_calls", "stop") }],
|
|
80
|
+
usage: {
|
|
81
|
+
prompt_tokens: usage.input,
|
|
82
|
+
completion_tokens: usage.output,
|
|
83
|
+
total_tokens: usage.input + usage.output,
|
|
84
|
+
prompt_tokens_details: { cached_tokens: 0, audio_tokens: 0 },
|
|
85
|
+
completion_tokens_details: { reasoning_tokens: 0, audio_tokens: 0, accepted_prediction_tokens: 0, rejected_prediction_tokens: 0 },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function serializeError(error: { status: number; message: string; type?: string }): unknown {
|
|
91
|
+
return { error: { message: error.message, type: error.type ?? "server_error", code: null } };
|
|
92
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FormatName, Message, MockRequest, ReplyObject, ToolDef } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const MS_PER_SECOND = 1000;
|
|
4
|
+
const BASE_36 = 36;
|
|
5
|
+
export const DEFAULT_USAGE = { input: 10, output: 5 } as const;
|
|
6
|
+
|
|
7
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
8
|
+
if (typeof body === "object" && body !== null) return body as Record<string, unknown>;
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function splitText(text: string, chunkSize: number): string[] {
|
|
13
|
+
if (chunkSize <= 0 || text.length <= chunkSize) return [text];
|
|
14
|
+
const chunks: string[] = [];
|
|
15
|
+
for (let i = 0; i < text.length; i += chunkSize) {
|
|
16
|
+
chunks.push(text.slice(i, i + chunkSize));
|
|
17
|
+
}
|
|
18
|
+
return chunks;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function genId(prefix: string): string {
|
|
22
|
+
return `${prefix}_${Date.now().toString(BASE_36)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toolId(tool: { id?: string | undefined }, prefix: string, index: number): string {
|
|
26
|
+
return tool.id ?? `${prefix}_${Date.now().toString(BASE_36)}_${index}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function shouldEmitText(reply: ReplyObject): boolean {
|
|
30
|
+
return Boolean(reply.text) || (!reply.tools?.length && !reply.reasoning);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function finishReason(reply: ReplyObject, onTools: string, onStop: string): string {
|
|
34
|
+
return reply.tools?.length ? onTools : onStop;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isStreaming(body: unknown): boolean {
|
|
38
|
+
return asRecord(body)["stream"] !== false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RequestMeta {
|
|
42
|
+
readonly headers: Readonly<Record<string, string | undefined>>;
|
|
43
|
+
readonly path: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const EMPTY_META: RequestMeta = { headers: {}, path: "" };
|
|
47
|
+
|
|
48
|
+
export interface ParsedBody {
|
|
49
|
+
readonly model?: string | undefined;
|
|
50
|
+
readonly stream?: boolean | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildMockRequest(
|
|
54
|
+
format: FormatName,
|
|
55
|
+
body: ParsedBody,
|
|
56
|
+
messages: readonly Message[],
|
|
57
|
+
tools: readonly ToolDef[] | undefined,
|
|
58
|
+
defaultModel: string,
|
|
59
|
+
raw: unknown,
|
|
60
|
+
meta: RequestMeta = EMPTY_META,
|
|
61
|
+
): MockRequest {
|
|
62
|
+
const userMessages = messages.filter((m) => m.role === "user");
|
|
63
|
+
const toolCallMsgs = messages.filter((m) => m.toolCallId !== undefined);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
format,
|
|
67
|
+
model: body.model || defaultModel,
|
|
68
|
+
streaming: body.stream !== false,
|
|
69
|
+
messages,
|
|
70
|
+
lastMessage: userMessages.at(-1)?.content ?? "",
|
|
71
|
+
systemMessage: messages.find((m) => m.role === "system")?.content ?? "",
|
|
72
|
+
tools,
|
|
73
|
+
toolNames: tools?.map((t) => t.name) ?? [],
|
|
74
|
+
lastToolCallId: toolCallMsgs.at(-1)?.toolCallId,
|
|
75
|
+
raw,
|
|
76
|
+
headers: meta.headers,
|
|
77
|
+
path: meta.path,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -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 responsesFormat: Format = {
|
|
7
|
+
name: "responses",
|
|
8
|
+
route: "/v1/responses",
|
|
9
|
+
parseRequest,
|
|
10
|
+
isStreaming,
|
|
11
|
+
serialize,
|
|
12
|
+
serializeComplete,
|
|
13
|
+
serializeError,
|
|
14
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { MockRequest, Message, ToolDef } from "../../types.js";
|
|
2
|
+
import { buildMockRequest, type RequestMeta } from "../parse-helpers.js";
|
|
3
|
+
import { ResponsesRequestSchema, FunctionToolSchema, type ResponsesRequest } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
function extractInputContent(content: string | Record<string, unknown>[]): string {
|
|
6
|
+
if (typeof content === "string") return content;
|
|
7
|
+
return content
|
|
8
|
+
.filter((b) => b["type"] === "input_text" || b["type"] === "text")
|
|
9
|
+
.map((b) => String(b["text"] ?? ""))
|
|
10
|
+
.join("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseInput(req: ResponsesRequest): readonly Message[] {
|
|
14
|
+
const instructions: Message[] = req.instructions
|
|
15
|
+
? [{ role: "system", content: req.instructions }]
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
if (req.input === undefined) return instructions;
|
|
19
|
+
|
|
20
|
+
if (typeof req.input === "string") {
|
|
21
|
+
return [...instructions, { role: "user", content: req.input }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const messages = req.input.map((item): Message => {
|
|
25
|
+
if ("call_id" in item) {
|
|
26
|
+
return {
|
|
27
|
+
role: "tool",
|
|
28
|
+
content: "output" in item ? item.output : item.arguments,
|
|
29
|
+
toolCallId: item.call_id,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
role: item.role === "developer" ? "system" : item.role,
|
|
34
|
+
content: extractInputContent(item.content),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return [...instructions, ...messages];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseTools(req: ResponsesRequest): readonly ToolDef[] | undefined {
|
|
42
|
+
if (!req.tools) return undefined;
|
|
43
|
+
return req.tools
|
|
44
|
+
.map((t) => FunctionToolSchema.safeParse(t))
|
|
45
|
+
.filter((r) => r.success)
|
|
46
|
+
.map((r) => ({
|
|
47
|
+
name: r.data.name,
|
|
48
|
+
description: r.data.description,
|
|
49
|
+
parameters: r.data.parameters,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseRequest(body: unknown, meta?: RequestMeta): MockRequest {
|
|
54
|
+
const req = ResponsesRequestSchema.parse(body);
|
|
55
|
+
return buildMockRequest("responses", req, parseInput(req), parseTools(req), "codex-mini", body, meta);
|
|
56
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const InputMessageSchema = z.object({
|
|
4
|
+
type: z.literal("message").optional(),
|
|
5
|
+
role: z.enum(["user", "assistant", "system", "developer"]),
|
|
6
|
+
content: z.union([z.string(), z.array(z.record(z.string(), z.unknown()))]),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const FunctionCallInputSchema = z.object({
|
|
10
|
+
type: z.literal("function_call"),
|
|
11
|
+
id: z.string().optional(),
|
|
12
|
+
call_id: z.string(),
|
|
13
|
+
name: z.string(),
|
|
14
|
+
arguments: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const FunctionCallOutputSchema = z.object({
|
|
18
|
+
type: z.literal("function_call_output"),
|
|
19
|
+
call_id: z.string(),
|
|
20
|
+
output: z.string(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const InputItemSchema = z.union([
|
|
24
|
+
InputMessageSchema,
|
|
25
|
+
FunctionCallInputSchema,
|
|
26
|
+
FunctionCallOutputSchema,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const RawToolSchema = z.record(z.string(), z.unknown());
|
|
30
|
+
|
|
31
|
+
export const FunctionToolSchema = z.object({
|
|
32
|
+
type: z.literal("function"),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
description: z.string().optional(),
|
|
35
|
+
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type FunctionTool = z.infer<typeof FunctionToolSchema>;
|
|
39
|
+
|
|
40
|
+
export const ResponsesRequestSchema = z.looseObject({
|
|
41
|
+
model: z.string().min(1).optional(),
|
|
42
|
+
input: z.union([z.string(), z.array(InputItemSchema)]).optional(),
|
|
43
|
+
instructions: z.string().optional(),
|
|
44
|
+
tools: z.array(RawToolSchema).optional(),
|
|
45
|
+
stream: z.boolean().optional(),
|
|
46
|
+
temperature: z.number().optional(),
|
|
47
|
+
previous_response_id: z.string().optional(),
|
|
48
|
+
background: z.boolean().optional(),
|
|
49
|
+
context_management: z.unknown().optional(),
|
|
50
|
+
conversation: z.unknown().optional(),
|
|
51
|
+
include: z.array(z.string()).optional(),
|
|
52
|
+
max_output_tokens: z.number().optional(),
|
|
53
|
+
max_tool_calls: z.number().optional(),
|
|
54
|
+
metadata: z.record(z.string(), z.string()).optional(),
|
|
55
|
+
parallel_tool_calls: z.boolean().optional(),
|
|
56
|
+
prompt: z.unknown().optional(),
|
|
57
|
+
prompt_cache_key: z.string().optional(),
|
|
58
|
+
prompt_cache_retention: z.string().optional(),
|
|
59
|
+
reasoning: z.unknown().optional(),
|
|
60
|
+
safety_identifier: z.string().optional(),
|
|
61
|
+
service_tier: z.string().optional(),
|
|
62
|
+
store: z.boolean().optional(),
|
|
63
|
+
stream_options: z.unknown().optional(),
|
|
64
|
+
text: z.unknown().optional(),
|
|
65
|
+
tool_choice: z.unknown().optional(),
|
|
66
|
+
top_logprobs: z.number().optional(),
|
|
67
|
+
top_p: z.number().optional(),
|
|
68
|
+
truncation: z.string().optional(),
|
|
69
|
+
user: z.string().optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export type ResponsesRequest = z.infer<typeof ResponsesRequestSchema>;
|
|
73
|
+
|
|
74
|
+
const OutputContentSchema = z.object({
|
|
75
|
+
type: z.string(),
|
|
76
|
+
text: z.string(),
|
|
77
|
+
annotations: z.array(z.unknown()).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const OutputItemSchema = z.object({
|
|
81
|
+
type: z.string(),
|
|
82
|
+
id: z.string().optional(),
|
|
83
|
+
status: z.string().optional(),
|
|
84
|
+
role: z.string().optional(),
|
|
85
|
+
content: z.array(OutputContentSchema).optional(),
|
|
86
|
+
call_id: z.string().optional(),
|
|
87
|
+
name: z.string().optional(),
|
|
88
|
+
arguments: z.string().optional(),
|
|
89
|
+
summary: z.array(z.object({ type: z.string(), text: z.string() })).optional(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export type ResponsesOutputItem = z.infer<typeof OutputItemSchema>;
|
|
93
|
+
|
|
94
|
+
export const ResponsesEventSchema = z.object({
|
|
95
|
+
type: z.string(),
|
|
96
|
+
sequence_number: z.number().optional(),
|
|
97
|
+
response: z.object({
|
|
98
|
+
id: z.string(),
|
|
99
|
+
object: z.string(),
|
|
100
|
+
created_at: z.number(),
|
|
101
|
+
model: z.string(),
|
|
102
|
+
status: z.string(),
|
|
103
|
+
output: z.array(OutputItemSchema),
|
|
104
|
+
usage: z.object({
|
|
105
|
+
input_tokens: z.number(),
|
|
106
|
+
output_tokens: z.number(),
|
|
107
|
+
total_tokens: z.number(),
|
|
108
|
+
}).optional(),
|
|
109
|
+
}).optional(),
|
|
110
|
+
item: OutputItemSchema.optional(),
|
|
111
|
+
part: z.object({
|
|
112
|
+
type: z.string(),
|
|
113
|
+
text: z.string().optional(),
|
|
114
|
+
annotations: z.array(z.unknown()).optional(),
|
|
115
|
+
}).optional(),
|
|
116
|
+
delta: z.string().optional(),
|
|
117
|
+
item_id: z.string().optional(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export type ResponsesEvent = z.infer<typeof ResponsesEventSchema>;
|
|
121
|
+
|
|
122
|
+
export const ResponsesCompleteSchema = z.object({
|
|
123
|
+
id: z.string(),
|
|
124
|
+
object: z.literal("response"),
|
|
125
|
+
created_at: z.number(),
|
|
126
|
+
status: z.literal("completed"),
|
|
127
|
+
model: z.string(),
|
|
128
|
+
output: z.array(OutputItemSchema),
|
|
129
|
+
usage: z.object({
|
|
130
|
+
input_tokens: z.number(),
|
|
131
|
+
output_tokens: z.number(),
|
|
132
|
+
total_tokens: z.number(),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export type ResponsesComplete = z.infer<typeof ResponsesCompleteSchema>;
|
|
137
|
+
|
|
138
|
+
export const ResponsesErrorSchema = z.object({
|
|
139
|
+
type: z.literal("error"),
|
|
140
|
+
error: z.object({ message: z.string(), type: z.string().optional(), code: z.string().optional() }),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export type ResponsesError = z.infer<typeof ResponsesErrorSchema>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReplyObject, ReplyOptions, ToolCall } from "../../types.js";
|
|
2
|
+
import type { SSEChunk } from "../types.js";
|
|
3
|
+
import { splitText, genId, toolId, shouldEmitText, MS_PER_SECOND, DEFAULT_USAGE } from "../parse-helpers.js";
|
|
4
|
+
|
|
5
|
+
interface StreamBlock { chunks: SSEChunk[]; outputItem: unknown }
|
|
6
|
+
|
|
7
|
+
const NO_ANNOTATIONS: readonly unknown[] = [];
|
|
8
|
+
|
|
9
|
+
type Chunk = (payload: Record<string, unknown>) => SSEChunk;
|
|
10
|
+
|
|
11
|
+
function createChunk(): Chunk {
|
|
12
|
+
let seq = 0;
|
|
13
|
+
return (payload) => ({ data: JSON.stringify({ ...payload, sequence_number: seq++ }) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function reasoningStreamBlock(c: Chunk, i: number, reasoning: string): StreamBlock {
|
|
17
|
+
const itemId = `rs_${genId("rs")}`;
|
|
18
|
+
const summaryPart = { type: "summary_text" as const, text: reasoning };
|
|
19
|
+
const item = { type: "reasoning", id: itemId, status: "completed", summary: [summaryPart] };
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
outputItem: item,
|
|
23
|
+
chunks: [
|
|
24
|
+
c({ type: "response.output_item.added", output_index: i, item: { type: "reasoning", id: itemId, status: "in_progress", summary: [] } }),
|
|
25
|
+
c({ type: "response.reasoning_summary_part.added", item_id: itemId, output_index: i, summary_index: 0, part: { type: "summary_text", text: "" } }),
|
|
26
|
+
c({ type: "response.reasoning_summary_text.delta", item_id: itemId, output_index: i, summary_index: 0, delta: reasoning }),
|
|
27
|
+
c({ type: "response.reasoning_summary_text.done", item_id: itemId, output_index: i, summary_index: 0, text: reasoning }),
|
|
28
|
+
c({ type: "response.reasoning_summary_part.done", item_id: itemId, output_index: i, summary_index: 0, part: summaryPart }),
|
|
29
|
+
c({ type: "response.output_item.done", output_index: i, item }),
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function textStreamBlock(c: Chunk, i: number, text: string, chunkSize: number): StreamBlock {
|
|
35
|
+
const itemId = `msg_${genId("msg")}`;
|
|
36
|
+
const outputText = { type: "output_text" as const, text, annotations: NO_ANNOTATIONS };
|
|
37
|
+
const outputItem = { type: "message", id: itemId, status: "completed", role: "assistant", content: [outputText] };
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
outputItem,
|
|
41
|
+
chunks: [
|
|
42
|
+
c({ type: "response.output_item.added", output_index: i, item: { type: "message", id: itemId, status: "in_progress", role: "assistant", content: [] } }),
|
|
43
|
+
c({ type: "response.content_part.added", item_id: itemId, output_index: i, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }),
|
|
44
|
+
...splitText(text, chunkSize).map((piece) =>
|
|
45
|
+
c({ type: "response.output_text.delta", item_id: itemId, output_index: i, content_index: 0, delta: piece }),
|
|
46
|
+
),
|
|
47
|
+
c({ type: "response.output_text.done", item_id: itemId, output_index: i, content_index: 0, text }),
|
|
48
|
+
c({ type: "response.content_part.done", item_id: itemId, output_index: i, content_index: 0, part: outputText }),
|
|
49
|
+
c({ type: "response.output_item.done", output_index: i, item: outputItem }),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolStreamBlock(c: Chunk, i: number, tool: ToolCall): StreamBlock {
|
|
55
|
+
const callId = toolId(tool, "call", i);
|
|
56
|
+
const argsJson = JSON.stringify(tool.args);
|
|
57
|
+
const outputItem = { type: "function_call", id: callId, status: "completed", name: tool.name, call_id: callId, arguments: argsJson };
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
outputItem,
|
|
61
|
+
chunks: [
|
|
62
|
+
c({ type: "response.output_item.added", output_index: i, item: { ...outputItem, status: "in_progress", arguments: "" } }),
|
|
63
|
+
c({ type: "response.function_call_arguments.delta", item_id: callId, output_index: i, delta: argsJson }),
|
|
64
|
+
c({ type: "response.function_call_arguments.done", item_id: callId, output_index: i, arguments: argsJson }),
|
|
65
|
+
c({ type: "response.output_item.done", output_index: i, item: outputItem }),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function serialize(reply: ReplyObject, model: string, options: ReplyOptions = {}): readonly SSEChunk[] {
|
|
71
|
+
const id = genId("resp");
|
|
72
|
+
const createdAt = Math.floor(Date.now() / MS_PER_SECOND);
|
|
73
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
74
|
+
const c = createChunk();
|
|
75
|
+
let i = 0;
|
|
76
|
+
|
|
77
|
+
const baseResponse = { id, object: "response", created_at: createdAt, model };
|
|
78
|
+
const header = [
|
|
79
|
+
c({ type: "response.created", response: { ...baseResponse, status: "in_progress", output: [] } }),
|
|
80
|
+
c({ type: "response.in_progress", response: { ...baseResponse, status: "in_progress", output: [] } }),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const blocks: StreamBlock[] = [
|
|
84
|
+
...(reply.reasoning ? [reasoningStreamBlock(c, i++, reply.reasoning)] : []),
|
|
85
|
+
...(shouldEmitText(reply) ? [textStreamBlock(c, i++, reply.text ?? "", options.chunkSize ?? 0)] : []),
|
|
86
|
+
...(reply.tools ?? []).map((tool) => toolStreamBlock(c, i++, tool)),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const allChunks = blocks.flatMap((b) => b.chunks);
|
|
90
|
+
const output = blocks.map((b) => b.outputItem);
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
...header,
|
|
94
|
+
...allChunks,
|
|
95
|
+
c({
|
|
96
|
+
type: "response.completed",
|
|
97
|
+
response: { ...baseResponse, status: "completed", output,
|
|
98
|
+
usage: { input_tokens: usage.input, output_tokens: usage.output, total_tokens: usage.input + usage.output } },
|
|
99
|
+
}),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function serializeComplete(reply: ReplyObject, model: string): unknown {
|
|
104
|
+
const id = genId("resp");
|
|
105
|
+
const createdAt = Math.floor(Date.now() / MS_PER_SECOND);
|
|
106
|
+
const usage = reply.usage ?? DEFAULT_USAGE;
|
|
107
|
+
|
|
108
|
+
const output: unknown[] = [
|
|
109
|
+
...(reply.reasoning
|
|
110
|
+
? [{ type: "reasoning", id: `rs_${genId("rs")}`, status: "completed", summary: [{ type: "summary_text", text: reply.reasoning }] }]
|
|
111
|
+
: []),
|
|
112
|
+
...(shouldEmitText(reply)
|
|
113
|
+
? [{ type: "message", id: `msg_${genId("msg")}`, status: "completed", role: "assistant", content: [{ type: "output_text", text: reply.text ?? "", annotations: [] }] }]
|
|
114
|
+
: []),
|
|
115
|
+
...(reply.tools ?? []).map((tool) => {
|
|
116
|
+
const callId = toolId(tool, "call", 0);
|
|
117
|
+
return { type: "function_call", id: callId, status: "completed", name: tool.name, call_id: callId, arguments: JSON.stringify(tool.args) };
|
|
118
|
+
}),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id, object: "response", created_at: createdAt, status: "completed", model, output,
|
|
123
|
+
usage: { input_tokens: usage.input, output_tokens: usage.output, total_tokens: usage.input + usage.output },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function serializeError(error: { status: number; message: string; type?: string }): unknown {
|
|
128
|
+
return { type: "error", error: { message: error.message, type: error.type ?? "server_error", code: error.type ?? "server_error" } };
|
|
129
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FormatName, MockRequest, ReplyObject, ReplyOptions } from "../types.js";
|
|
2
|
+
import type { RequestMeta } from "./parse-helpers.js";
|
|
3
|
+
|
|
4
|
+
export interface SSEChunk {
|
|
5
|
+
readonly event?: string | undefined;
|
|
6
|
+
readonly data: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Format {
|
|
10
|
+
readonly name: FormatName;
|
|
11
|
+
readonly route: string;
|
|
12
|
+
parseRequest(body: unknown, meta?: RequestMeta): MockRequest;
|
|
13
|
+
isStreaming(body: unknown): boolean;
|
|
14
|
+
serialize(reply: ReplyObject, model: string, options?: ReplyOptions): readonly SSEChunk[];
|
|
15
|
+
serializeComplete(reply: ReplyObject, model: string): unknown;
|
|
16
|
+
serializeError(error: { status: number; message: string; type?: string | undefined }): unknown;
|
|
17
|
+
}
|