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,243 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { openaiFormat } from "../../src/formats/openai/index.js";
|
|
3
|
+
import type { OpenAIChunk, OpenAIComplete, OpenAIError } from "../../src/formats/openai/schema.js";
|
|
4
|
+
|
|
5
|
+
function parse<T>(chunk: { data: string }): T {
|
|
6
|
+
return JSON.parse(chunk.data) as T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("OpenAI Format", () => {
|
|
10
|
+
describe("parseRequest", () => {
|
|
11
|
+
it("parses a basic chat completion request", () => {
|
|
12
|
+
const req = openaiFormat.parseRequest({
|
|
13
|
+
model: "gpt-5.4",
|
|
14
|
+
messages: [
|
|
15
|
+
{ role: "system", content: "You are helpful" },
|
|
16
|
+
{ role: "user", content: "Hello" },
|
|
17
|
+
],
|
|
18
|
+
stream: true,
|
|
19
|
+
});
|
|
20
|
+
expect(req.format).toBe("openai");
|
|
21
|
+
expect(req.model).toBe("gpt-5.4");
|
|
22
|
+
expect(req.streaming).toBe(true);
|
|
23
|
+
expect(req.lastMessage).toBe("Hello");
|
|
24
|
+
expect(req.systemMessage).toBe("You are helpful");
|
|
25
|
+
expect(req.messages).toHaveLength(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("defaults stream to true", () => {
|
|
29
|
+
const req = openaiFormat.parseRequest({ model: "gpt-5.4", messages: [{ role: "user", content: "hi" }] });
|
|
30
|
+
expect(req.streaming).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("detects stream: false", () => {
|
|
34
|
+
const req = openaiFormat.parseRequest({ model: "gpt-5.4", messages: [{ role: "user", content: "hi" }], stream: false });
|
|
35
|
+
expect(req.streaming).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("parses tools with function wrapper", () => {
|
|
39
|
+
const req = openaiFormat.parseRequest({
|
|
40
|
+
model: "gpt-5.4",
|
|
41
|
+
messages: [{ role: "user", content: "read file" }],
|
|
42
|
+
tools: [{ type: "function", function: { name: "read_file", description: "Read a file", parameters: {} } }],
|
|
43
|
+
});
|
|
44
|
+
expect(req.tools).toHaveLength(1);
|
|
45
|
+
expect(req.tools![0]!.name).toBe("read_file");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("extracts toolNames from tools array", () => {
|
|
49
|
+
const req = openaiFormat.parseRequest({
|
|
50
|
+
model: "gpt-5.4",
|
|
51
|
+
messages: [{ role: "user", content: "hi" }],
|
|
52
|
+
tools: [
|
|
53
|
+
{ type: "function", function: { name: "get_weather" } },
|
|
54
|
+
{ type: "function", function: { name: "search" } },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
expect(req.toolNames).toEqual(["get_weather", "search"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("extracts lastToolCallId from tool messages", () => {
|
|
61
|
+
const req = openaiFormat.parseRequest({
|
|
62
|
+
model: "gpt-5.4",
|
|
63
|
+
messages: [
|
|
64
|
+
{ role: "user", content: "hi" },
|
|
65
|
+
{ role: "tool", tool_call_id: "call_123", content: "result" },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
expect(req.lastToolCallId).toBe("call_123");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles non-string content (array of content parts)", () => {
|
|
72
|
+
const req = openaiFormat.parseRequest({
|
|
73
|
+
model: "gpt-5.4",
|
|
74
|
+
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
|
75
|
+
});
|
|
76
|
+
expect(req.lastMessage).toContain("Hello");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects requests with invalid role values", () => {
|
|
80
|
+
expect(() => openaiFormat.parseRequest({
|
|
81
|
+
model: "gpt-5.4",
|
|
82
|
+
messages: [{ role: "banana", content: "hi" }],
|
|
83
|
+
})).toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects requests missing model", () => {
|
|
87
|
+
expect(() => openaiFormat.parseRequest({
|
|
88
|
+
messages: [{ role: "user", content: "hi" }],
|
|
89
|
+
})).toThrow();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("serialize (streaming)", () => {
|
|
94
|
+
it("starts with role delta and ends with [DONE]", () => {
|
|
95
|
+
const chunks = openaiFormat.serialize({ text: "Hello world" }, "gpt-5.4");
|
|
96
|
+
const first = parse<OpenAIChunk>(chunks[0]!);
|
|
97
|
+
expect(first.choices[0]!.delta).toEqual({ role: "assistant" });
|
|
98
|
+
expect(chunks.at(-1)!.data).toBe("[DONE]");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("content delta has correct structure", () => {
|
|
102
|
+
const chunks = openaiFormat.serialize({ text: "Hello world" }, "gpt-5.4");
|
|
103
|
+
const content = parse<OpenAIChunk>(chunks[1]!);
|
|
104
|
+
expect(content.object).toBe("chat.completion.chunk");
|
|
105
|
+
expect(content.model).toBe("gpt-5.4");
|
|
106
|
+
expect(content.choices[0]!.delta).toEqual({ content: "Hello world" });
|
|
107
|
+
expect(content.choices[0]!.finish_reason).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("finish chunk has finish_reason: stop for text", () => {
|
|
111
|
+
const chunks = openaiFormat.serialize({ text: "Hello" }, "gpt-5.4");
|
|
112
|
+
const finish = parse<OpenAIChunk>(chunks.at(-3)!);
|
|
113
|
+
expect(finish.choices[0]!.finish_reason).toBe("stop");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("finish chunk has finish_reason: tool_calls for tools", () => {
|
|
117
|
+
const chunks = openaiFormat.serialize(
|
|
118
|
+
{ tools: [{ name: "read_file", args: { path: "/tmp" } }] },
|
|
119
|
+
"gpt-5.4",
|
|
120
|
+
);
|
|
121
|
+
const finish = parse<OpenAIChunk>(chunks.at(-3)!);
|
|
122
|
+
expect(finish.choices[0]!.finish_reason).toBe("tool_calls");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("includes usage chunk before [DONE]", () => {
|
|
126
|
+
const chunks = openaiFormat.serialize({ text: "Hello", usage: { input: 10, output: 5 } }, "gpt-5.4");
|
|
127
|
+
const usageChunk = parse<OpenAIChunk>(chunks.at(-2)!);
|
|
128
|
+
expect(usageChunk.usage).toMatchObject({
|
|
129
|
+
prompt_tokens: 10,
|
|
130
|
+
completion_tokens: 5,
|
|
131
|
+
total_tokens: 15,
|
|
132
|
+
});
|
|
133
|
+
expect(usageChunk.usage?.completion_tokens_details?.reasoning_tokens).toBe(0);
|
|
134
|
+
expect(usageChunk.usage?.prompt_tokens_details?.cached_tokens).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("tool call delta has correct structure", () => {
|
|
138
|
+
const chunks = openaiFormat.serialize(
|
|
139
|
+
{ tools: [{ name: "read_file", args: { path: "/tmp" } }] },
|
|
140
|
+
"gpt-5.4",
|
|
141
|
+
);
|
|
142
|
+
const toolChunk = chunks.find((c) => {
|
|
143
|
+
if (c.data === "[DONE]") return false;
|
|
144
|
+
return parse<OpenAIChunk>(c).choices[0]?.delta.tool_calls !== undefined;
|
|
145
|
+
});
|
|
146
|
+
expect(toolChunk).toBeDefined();
|
|
147
|
+
const tc = parse<OpenAIChunk>(toolChunk!).choices[0]!.delta.tool_calls![0]!;
|
|
148
|
+
expect(tc.type).toBe("function");
|
|
149
|
+
expect(tc.id).toBeTypeOf("string");
|
|
150
|
+
expect(tc.function.name).toBe("read_file");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("no named events (openai uses data-only SSE)", () => {
|
|
154
|
+
const chunks = openaiFormat.serialize({ text: "hi" }, "gpt-5.4");
|
|
155
|
+
for (const chunk of chunks) {
|
|
156
|
+
expect(chunk.event).toBeUndefined();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("splits text into multiple delta chunks with chunkSize", () => {
|
|
161
|
+
const chunks = openaiFormat.serialize({ text: "Hello, world!" }, "gpt-5.4", { chunkSize: 5 });
|
|
162
|
+
const contentDeltas = chunks
|
|
163
|
+
.filter((c) => c.data !== "[DONE]")
|
|
164
|
+
.map((c) => parse<OpenAIChunk>(c))
|
|
165
|
+
.filter((d) => d.choices[0]?.delta.content !== undefined)
|
|
166
|
+
.map((d) => d.choices[0]!.delta.content);
|
|
167
|
+
expect(contentDeltas).toEqual(["Hello", ", wor", "ld!"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("all chunks share same id and created timestamp", () => {
|
|
171
|
+
const chunks = openaiFormat.serialize({ text: "Hello" }, "gpt-5.4");
|
|
172
|
+
const dataChunks = chunks.filter((c) => c.data !== "[DONE]").map((c) => parse<OpenAIChunk>(c));
|
|
173
|
+
const ids = dataChunks.map((c) => c.id);
|
|
174
|
+
const created = dataChunks.map((c) => c.created);
|
|
175
|
+
expect(new Set(ids).size).toBe(1);
|
|
176
|
+
expect(new Set(created).size).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("serializeComplete (non-streaming)", () => {
|
|
181
|
+
it("produces correct top-level structure", () => {
|
|
182
|
+
const result = openaiFormat.serializeComplete({ text: "Hello, world!" }, "gpt-5.4") as OpenAIComplete;
|
|
183
|
+
expect(result.object).toBe("chat.completion");
|
|
184
|
+
expect(result.model).toBe("gpt-5.4");
|
|
185
|
+
expect(result.id).toBeTypeOf("string");
|
|
186
|
+
expect(result.created).toBeTypeOf("number");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("message has correct content and finish_reason", () => {
|
|
190
|
+
const result = openaiFormat.serializeComplete({ text: "Hello, world!" }, "gpt-5.4") as OpenAIComplete;
|
|
191
|
+
expect(result.choices[0]!.message.role).toBe("assistant");
|
|
192
|
+
expect(result.choices[0]!.message.content).toBe("Hello, world!");
|
|
193
|
+
expect(result.choices[0]!.finish_reason).toBe("stop");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("includes tool_calls with correct structure", () => {
|
|
197
|
+
const result = openaiFormat.serializeComplete(
|
|
198
|
+
{ tools: [{ name: "read_file", args: { path: "/tmp" } }] },
|
|
199
|
+
"gpt-5.4",
|
|
200
|
+
) as OpenAIComplete;
|
|
201
|
+
expect(result.choices[0]!.finish_reason).toBe("tool_calls");
|
|
202
|
+
const tc = result.choices[0]!.message.tool_calls![0]!;
|
|
203
|
+
expect(tc.type).toBe("function");
|
|
204
|
+
expect(tc.id).toBeTypeOf("string");
|
|
205
|
+
expect(tc.function.name).toBe("read_file");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("includes usage tokens with details", () => {
|
|
209
|
+
const result = openaiFormat.serializeComplete(
|
|
210
|
+
{ text: "hi", usage: { input: 20, output: 15 } },
|
|
211
|
+
"gpt-5.4",
|
|
212
|
+
) as OpenAIComplete;
|
|
213
|
+
expect(result.usage).toMatchObject({ prompt_tokens: 20, completion_tokens: 15, total_tokens: 35 });
|
|
214
|
+
expect(result.usage?.completion_tokens_details?.reasoning_tokens).toBe(0);
|
|
215
|
+
expect(result.usage?.prompt_tokens_details?.cached_tokens).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("includes service_tier and system_fingerprint", () => {
|
|
219
|
+
const result = openaiFormat.serializeComplete({ text: "hi" }, "gpt-5.4") as OpenAIComplete;
|
|
220
|
+
expect(result.service_tier).toBe("default");
|
|
221
|
+
expect(result.system_fingerprint).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("includes logprobs: null on choices", () => {
|
|
225
|
+
const result = openaiFormat.serializeComplete({ text: "hi" }, "gpt-5.4") as OpenAIComplete;
|
|
226
|
+
expect(result.choices[0]!.logprobs).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("serializeError", () => {
|
|
231
|
+
it("produces OpenAI error format", () => {
|
|
232
|
+
const result = openaiFormat.serializeError({ status: 429, message: "Rate limited", type: "rate_limit_error" }) as OpenAIError;
|
|
233
|
+
expect(result.error.message).toBe("Rate limited");
|
|
234
|
+
expect(result.error.type).toBe("rate_limit_error");
|
|
235
|
+
expect(result.error.code).toBeNull();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("defaults type to server_error", () => {
|
|
239
|
+
const result = openaiFormat.serializeError({ status: 500, message: "Internal" }) as OpenAIError;
|
|
240
|
+
expect(result.error.type).toBe("server_error");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ResponsesRequestSchema, FunctionToolSchema } from "../../src/formats/responses/schema.js";
|
|
3
|
+
|
|
4
|
+
describe("ResponsesRequestSchema", () => {
|
|
5
|
+
const validRequest = {
|
|
6
|
+
model: "codex-mini",
|
|
7
|
+
input: "Hello",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
it("accepts a valid minimal request with string input", () => {
|
|
11
|
+
expect(ResponsesRequestSchema.safeParse(validRequest).success).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("accepts array input with a user message", () => {
|
|
15
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
16
|
+
model: "codex-mini",
|
|
17
|
+
input: [{ role: "user", content: "Hello" }],
|
|
18
|
+
}).success).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("accepts array input with function_call and function_call_output", () => {
|
|
22
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
23
|
+
model: "codex-mini",
|
|
24
|
+
input: [
|
|
25
|
+
{ type: "function_call", call_id: "call_1", name: "search", arguments: "{}" },
|
|
26
|
+
{ type: "function_call_output", call_id: "call_1", output: "result" },
|
|
27
|
+
],
|
|
28
|
+
}).success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("accepts missing model", () => {
|
|
32
|
+
expect(ResponsesRequestSchema.safeParse({ input: "Hello" }).success).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects empty model string", () => {
|
|
36
|
+
expect(ResponsesRequestSchema.safeParse({ model: "", input: "Hello" }).success).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts missing input", () => {
|
|
40
|
+
expect(ResponsesRequestSchema.safeParse({ model: "codex-mini" }).success).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("accepts stream: true", () => {
|
|
44
|
+
const result = ResponsesRequestSchema.safeParse({ ...validRequest, stream: true });
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
if (result.success) expect(result.data.stream).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts stream: false", () => {
|
|
50
|
+
const result = ResponsesRequestSchema.safeParse({ ...validRequest, stream: false });
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
if (result.success) expect(result.data.stream).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("accepts optional fields", () => {
|
|
56
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
57
|
+
...validRequest,
|
|
58
|
+
instructions: "Be helpful",
|
|
59
|
+
temperature: 0.5,
|
|
60
|
+
previous_response_id: "resp_abc",
|
|
61
|
+
}).success).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts tools array with function tools", () => {
|
|
65
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
66
|
+
...validRequest,
|
|
67
|
+
tools: [{
|
|
68
|
+
type: "function",
|
|
69
|
+
name: "search",
|
|
70
|
+
description: "Search the web",
|
|
71
|
+
parameters: { type: "object", properties: {} },
|
|
72
|
+
}],
|
|
73
|
+
}).success).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts tools array with non-function tools", () => {
|
|
77
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
78
|
+
...validRequest,
|
|
79
|
+
tools: [{ type: "web_search" }],
|
|
80
|
+
}).success).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts message input with array content", () => {
|
|
84
|
+
expect(ResponsesRequestSchema.safeParse({
|
|
85
|
+
model: "codex-mini",
|
|
86
|
+
input: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
|
87
|
+
}).success).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("FunctionToolSchema", () => {
|
|
92
|
+
it("accepts a valid function tool", () => {
|
|
93
|
+
const result = FunctionToolSchema.safeParse({
|
|
94
|
+
type: "function",
|
|
95
|
+
name: "search",
|
|
96
|
+
description: "Search the web",
|
|
97
|
+
parameters: { type: "object" },
|
|
98
|
+
});
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("accepts a function tool without optional fields", () => {
|
|
103
|
+
const result = FunctionToolSchema.safeParse({ type: "function", name: "run" });
|
|
104
|
+
expect(result.success).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects a non-function tool", () => {
|
|
108
|
+
expect(FunctionToolSchema.safeParse({ type: "web_search" }).success).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects a tool missing name", () => {
|
|
112
|
+
expect(FunctionToolSchema.safeParse({ type: "function" }).success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { responsesFormat } from "../../src/formats/responses/index.js";
|
|
3
|
+
import type { ResponsesEvent, ResponsesComplete, ResponsesError } from "../../src/formats/responses/schema.js";
|
|
4
|
+
|
|
5
|
+
function parse<T>(chunk: { data: string }): T {
|
|
6
|
+
return JSON.parse(chunk.data) as T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("Responses Format", () => {
|
|
10
|
+
describe("parseRequest", () => {
|
|
11
|
+
it("parses string input", () => {
|
|
12
|
+
const req = responsesFormat.parseRequest({ model: "codex-mini", input: "Hello world" });
|
|
13
|
+
expect(req.format).toBe("responses");
|
|
14
|
+
expect(req.model).toBe("codex-mini");
|
|
15
|
+
expect(req.lastMessage).toBe("Hello world");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses array input with items", () => {
|
|
19
|
+
const req = responsesFormat.parseRequest({
|
|
20
|
+
model: "codex-mini",
|
|
21
|
+
input: [
|
|
22
|
+
{ role: "user", content: "Hello" },
|
|
23
|
+
{ role: "assistant", content: "Hi" },
|
|
24
|
+
{ role: "user", content: "How are you?" },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
expect(req.lastMessage).toBe("How are you?");
|
|
28
|
+
expect(req.messages).toHaveLength(3);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses instructions as system message", () => {
|
|
32
|
+
const req = responsesFormat.parseRequest({
|
|
33
|
+
model: "codex-mini",
|
|
34
|
+
input: "Hello",
|
|
35
|
+
instructions: "You are a helpful assistant",
|
|
36
|
+
});
|
|
37
|
+
expect(req.systemMessage).toBe("You are a helpful assistant");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("parses content block arrays", () => {
|
|
41
|
+
const req = responsesFormat.parseRequest({
|
|
42
|
+
model: "codex-mini",
|
|
43
|
+
input: [{ role: "user", content: [{ type: "input_text", text: "Hello there" }] }],
|
|
44
|
+
});
|
|
45
|
+
expect(req.lastMessage).toBe("Hello there");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("parses tools", () => {
|
|
49
|
+
const req = responsesFormat.parseRequest({
|
|
50
|
+
model: "codex-mini",
|
|
51
|
+
input: "read file",
|
|
52
|
+
tools: [{ type: "function", name: "read_file", description: "Read", parameters: {} }],
|
|
53
|
+
});
|
|
54
|
+
expect(req.tools).toHaveLength(1);
|
|
55
|
+
expect(req.toolNames).toEqual(["read_file"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("extracts lastToolCallId from function_call_output items", () => {
|
|
59
|
+
const req = responsesFormat.parseRequest({
|
|
60
|
+
model: "codex-mini",
|
|
61
|
+
input: [
|
|
62
|
+
{ role: "user", content: "hi" },
|
|
63
|
+
{ type: "function_call_output", call_id: "call_abc", output: "result" },
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
expect(req.lastToolCallId).toBe("call_abc");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles content blocks with non-text types (image, etc.)", () => {
|
|
70
|
+
const req = responsesFormat.parseRequest({
|
|
71
|
+
model: "codex-mini",
|
|
72
|
+
input: [{ role: "user", content: [
|
|
73
|
+
{ type: "image_url", url: "https://example.com/img.png" },
|
|
74
|
+
{ type: "input_text", text: "describe this" },
|
|
75
|
+
]}],
|
|
76
|
+
});
|
|
77
|
+
expect(req.lastMessage).toBe("describe this");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("accepts requests with only instructions (no input)", () => {
|
|
81
|
+
const req = responsesFormat.parseRequest({
|
|
82
|
+
model: "codex-mini",
|
|
83
|
+
instructions: "You are helpful",
|
|
84
|
+
});
|
|
85
|
+
expect(req.systemMessage).toBe("You are helpful");
|
|
86
|
+
expect(req.lastMessage).toBe("");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("parses function tools without description", () => {
|
|
90
|
+
const req = responsesFormat.parseRequest({
|
|
91
|
+
model: "codex-mini",
|
|
92
|
+
input: "hi",
|
|
93
|
+
tools: [{ type: "function", name: "run_code" }],
|
|
94
|
+
});
|
|
95
|
+
expect(req.tools![0]!.name).toBe("run_code");
|
|
96
|
+
expect(req.tools![0]!.description).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("filters out non-function tools", () => {
|
|
100
|
+
const req = responsesFormat.parseRequest({
|
|
101
|
+
model: "codex-mini",
|
|
102
|
+
input: "hi",
|
|
103
|
+
tools: [
|
|
104
|
+
{ type: "function", name: "run_code" },
|
|
105
|
+
{ type: "web_search" },
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
expect(req.tools).toHaveLength(1);
|
|
109
|
+
expect(req.tools![0]!.name).toBe("run_code");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("serialize (streaming)", () => {
|
|
114
|
+
it("starts with response.created and response.in_progress", () => {
|
|
115
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
116
|
+
expect(parse<ResponsesEvent>(chunks[0]!).type).toBe("response.created");
|
|
117
|
+
expect(parse<ResponsesEvent>(chunks[1]!).type).toBe("response.in_progress");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("ends with response.completed", () => {
|
|
121
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
122
|
+
expect(parse<ResponsesEvent>(chunks.at(-1)!).type).toBe("response.completed");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("assigns incrementing sequence_number to every event", () => {
|
|
126
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
127
|
+
const seqNumbers = chunks.map((c) => parse<ResponsesEvent>(c).sequence_number!);
|
|
128
|
+
for (let i = 1; i < seqNumbers.length; i++) {
|
|
129
|
+
expect(seqNumbers[i]).toBe(seqNumbers[i - 1]! + 1);
|
|
130
|
+
}
|
|
131
|
+
expect(seqNumbers[0]).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("uses same created_at across created, in_progress, and completed envelopes", () => {
|
|
135
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
136
|
+
const created = parse<ResponsesEvent>(chunks[0]!).response?.created_at;
|
|
137
|
+
const inProgress = parse<ResponsesEvent>(chunks[1]!).response?.created_at;
|
|
138
|
+
const completed = parse<ResponsesEvent>(chunks.at(-1)!).response?.created_at;
|
|
139
|
+
expect(created).toBe(inProgress);
|
|
140
|
+
expect(created).toBe(completed);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("produces text delta events with item_id", () => {
|
|
144
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
145
|
+
const delta = chunks.find((c) => parse<ResponsesEvent>(c).type === "response.output_text.delta");
|
|
146
|
+
expect(delta).toBeDefined();
|
|
147
|
+
const data = parse<ResponsesEvent>(delta!);
|
|
148
|
+
expect(data.delta).toBe("Hello");
|
|
149
|
+
expect(data.item_id).toBeTypeOf("string");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("output items have status: in_progress when added, completed when done", () => {
|
|
153
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
154
|
+
const added = chunks.find((c) => {
|
|
155
|
+
const d = parse<ResponsesEvent>(c);
|
|
156
|
+
return d.type === "response.output_item.added" && d.item?.type === "message";
|
|
157
|
+
});
|
|
158
|
+
expect(parse<ResponsesEvent>(added!).item?.status).toBe("in_progress");
|
|
159
|
+
|
|
160
|
+
const done = chunks.find((c) => {
|
|
161
|
+
const d = parse<ResponsesEvent>(c);
|
|
162
|
+
return d.type === "response.output_item.done" && d.item?.type === "message";
|
|
163
|
+
});
|
|
164
|
+
expect(parse<ResponsesEvent>(done!).item?.status).toBe("completed");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("includes annotations on output_text parts", () => {
|
|
168
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
169
|
+
const partAdded = chunks.find((c) => parse<ResponsesEvent>(c).type === "response.content_part.added");
|
|
170
|
+
expect(parse<ResponsesEvent>(partAdded!).part?.annotations).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("includes content_part.done event with full text", () => {
|
|
174
|
+
const chunks = responsesFormat.serialize({ text: "Hello" }, "codex-mini");
|
|
175
|
+
const partDone = chunks.find((c) => parse<ResponsesEvent>(c).type === "response.content_part.done");
|
|
176
|
+
expect(partDone).toBeDefined();
|
|
177
|
+
expect(parse<ResponsesEvent>(partDone!).part?.text).toBe("Hello");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("emits reasoning events before message events", () => {
|
|
181
|
+
const chunks = responsesFormat.serialize(
|
|
182
|
+
{ text: "42", reasoning: "Let me think..." },
|
|
183
|
+
"codex-mini",
|
|
184
|
+
);
|
|
185
|
+
const types = chunks.map((c) => parse<ResponsesEvent>(c).type);
|
|
186
|
+
|
|
187
|
+
expect(types).toContain("response.reasoning_summary_part.added");
|
|
188
|
+
expect(types).toContain("response.reasoning_summary_text.delta");
|
|
189
|
+
expect(types).toContain("response.reasoning_summary_text.done");
|
|
190
|
+
expect(types).toContain("response.reasoning_summary_part.done");
|
|
191
|
+
|
|
192
|
+
const reasoningDone = types.indexOf("response.reasoning_summary_text.done");
|
|
193
|
+
const textDelta = types.indexOf("response.output_text.delta");
|
|
194
|
+
expect(reasoningDone).toBeLessThan(textDelta);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("includes reasoning output item in completed response", () => {
|
|
198
|
+
const chunks = responsesFormat.serialize(
|
|
199
|
+
{ text: "42", reasoning: "Let me think" },
|
|
200
|
+
"codex-mini",
|
|
201
|
+
);
|
|
202
|
+
const completed = parse<ResponsesEvent>(chunks.at(-1)!);
|
|
203
|
+
expect(completed.response?.output[0]!.type).toBe("reasoning");
|
|
204
|
+
expect(completed.response?.output[1]!.type).toBe("message");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("accumulates text in completed output", () => {
|
|
208
|
+
const chunks = responsesFormat.serialize({ text: "hello world" }, "codex-mini");
|
|
209
|
+
const completed = parse<ResponsesEvent>(chunks.at(-1)!);
|
|
210
|
+
expect(completed.response?.output[0]!.content?.[0]?.text).toBe("hello world");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("produces function_call events for tool calls", () => {
|
|
214
|
+
const chunks = responsesFormat.serialize(
|
|
215
|
+
{ tools: [{ name: "read_file", args: { path: "/tmp" } }] },
|
|
216
|
+
"codex-mini",
|
|
217
|
+
);
|
|
218
|
+
const fnAdded = chunks.find((c) => {
|
|
219
|
+
const d = parse<ResponsesEvent>(c);
|
|
220
|
+
return d.type === "response.output_item.added" && d.item?.type === "function_call";
|
|
221
|
+
});
|
|
222
|
+
expect(fnAdded).toBeDefined();
|
|
223
|
+
const item = parse<ResponsesEvent>(fnAdded!).item!;
|
|
224
|
+
expect(item.name).toBe("read_file");
|
|
225
|
+
expect(item.status).toBe("in_progress");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("no named events (responses uses data-only SSE)", () => {
|
|
229
|
+
const chunks = responsesFormat.serialize({ text: "hi" }, "codex-mini");
|
|
230
|
+
for (const chunk of chunks) {
|
|
231
|
+
expect(chunk.event).toBeUndefined();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("works without reasoning events", () => {
|
|
236
|
+
const chunks = responsesFormat.serialize({ text: "hello" }, "codex-mini");
|
|
237
|
+
const completed = parse<ResponsesEvent>(chunks.at(-1)!);
|
|
238
|
+
expect(completed.response?.output).toHaveLength(1);
|
|
239
|
+
expect(completed.response?.output[0]!.type).toBe("message");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("serializeComplete (non-streaming)", () => {
|
|
244
|
+
it("produces correct top-level structure", () => {
|
|
245
|
+
const result = responsesFormat.serializeComplete({ text: "Hello" }, "codex-mini") as ResponsesComplete;
|
|
246
|
+
expect(result.object).toBe("response");
|
|
247
|
+
expect(result.status).toBe("completed");
|
|
248
|
+
expect(result.model).toBe("codex-mini");
|
|
249
|
+
expect(result.created_at).toBeTypeOf("number");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("includes message output item with status and annotations", () => {
|
|
253
|
+
const result = responsesFormat.serializeComplete({ text: "Hello, world!" }, "codex-mini") as ResponsesComplete;
|
|
254
|
+
const msg = result.output[0]!;
|
|
255
|
+
expect(msg.type).toBe("message");
|
|
256
|
+
expect(msg.status).toBe("completed");
|
|
257
|
+
expect(msg.role).toBe("assistant");
|
|
258
|
+
expect(msg.content?.[0]?.type).toBe("output_text");
|
|
259
|
+
expect(msg.content?.[0]?.text).toBe("Hello, world!");
|
|
260
|
+
expect(msg.content?.[0]?.annotations).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("includes reasoning before message in output", () => {
|
|
264
|
+
const result = responsesFormat.serializeComplete(
|
|
265
|
+
{ text: "42", reasoning: "Thinking..." },
|
|
266
|
+
"codex-mini",
|
|
267
|
+
) as ResponsesComplete;
|
|
268
|
+
expect(result.output[0]!.type).toBe("reasoning");
|
|
269
|
+
expect(result.output[1]!.type).toBe("message");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("includes function_call in output for tool calls", () => {
|
|
273
|
+
const result = responsesFormat.serializeComplete(
|
|
274
|
+
{ tools: [{ name: "read_file", args: { path: "/tmp" } }] },
|
|
275
|
+
"codex-mini",
|
|
276
|
+
) as ResponsesComplete;
|
|
277
|
+
const fnCall = result.output.find((o) => o.type === "function_call");
|
|
278
|
+
expect(fnCall).toBeDefined();
|
|
279
|
+
expect(fnCall!.name).toBe("read_file");
|
|
280
|
+
expect(fnCall!.status).toBe("completed");
|
|
281
|
+
expect(fnCall!.call_id).toBeTypeOf("string");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("includes usage tokens", () => {
|
|
285
|
+
const result = responsesFormat.serializeComplete(
|
|
286
|
+
{ text: "hi", usage: { input: 20, output: 15 } },
|
|
287
|
+
"codex-mini",
|
|
288
|
+
) as ResponsesComplete;
|
|
289
|
+
expect(result.usage).toEqual({ input_tokens: 20, output_tokens: 15, total_tokens: 35 });
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("serializeError", () => {
|
|
294
|
+
it("produces Responses error format", () => {
|
|
295
|
+
const result = responsesFormat.serializeError({ status: 500, message: "Internal error" }) as ResponsesError;
|
|
296
|
+
expect(result.error.message).toBe("Internal error");
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|