Files
2026-07-13 13:39:12 +08:00

176 lines
5.2 KiB
TypeScript

import http from "node:http";
import net from "node:net";
export interface PlannedResponse {
status: number;
body: Record<string, unknown>;
headers?: Record<string, string>;
delayMs?: number;
}
export interface TokenState {
defaultResponse: PlannedResponse;
queue: PlannedResponse[];
hits: number;
startedAt: number[];
bodies: Array<Record<string, unknown>>;
}
function getFreePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to allocate a free port"));
return;
}
const { port } = address;
server.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
export function buildCompletion(
text: string,
overrides: Partial<PlannedResponse> & { model?: string } = {}
) {
return {
status: overrides.status ?? 200,
delayMs: overrides.delayMs,
headers: overrides.headers,
body: overrides.body ?? {
id: `chatcmpl_${Math.random().toString(16).slice(2, 8)}`,
object: "chat.completion",
model: overrides.model ?? "test-model",
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
},
};
}
export function buildError(status: number, message: string, headers: Record<string, string> = {}) {
return {
status,
headers,
body: { error: { message } },
};
}
export class MockUpstreamServer {
private behaviors = new Map<string, TokenState>();
private server: http.Server | null = null;
private _baseUrl = "";
get baseUrl(): string {
if (!this._baseUrl) throw new Error("Server not started yet");
return this._baseUrl;
}
get isRunning(): boolean {
return this.server !== null;
}
async start(): Promise<string> {
const port = await getFreePort();
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res);
});
await new Promise<void>((resolve, reject) => {
this.server!.once("error", reject);
this.server!.listen(port, "127.0.0.1", () => resolve());
});
this._baseUrl = `http://127.0.0.1:${port}/v1`;
return this._baseUrl;
}
configureToken(
token: string,
config: { defaultResponse: PlannedResponse; queue?: PlannedResponse[] }
): void {
this.behaviors.set(token, {
defaultResponse: config.defaultResponse,
queue: [...(config.queue || [])],
hits: 0,
startedAt: [],
bodies: [],
});
}
getState(token: string): TokenState {
const state = this.behaviors.get(token);
if (!state) throw new Error(`Unknown token: ${token}`);
return state;
}
resetState(token: string, queue?: PlannedResponse[]): void {
const state = this.behaviors.get(token);
if (!state) throw new Error(`Unknown token: ${token}`);
state.hits = 0;
state.startedAt = [];
state.bodies = [];
state.queue = [...(queue || [])];
}
async stop(): Promise<void> {
if (!this.server) return;
await new Promise<void>((resolve) => this.server?.close(() => resolve()));
this.server = null;
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const authHeader = String(req.headers.authorization || "");
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
const rawBody = Buffer.concat(chunks).toString("utf8");
const parsedBody = rawBody ? JSON.parse(rawBody) : {};
if (req.method === "GET" && req.url?.startsWith("/v1/models")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: [{ id: "test-model", object: "model" }] }));
return;
}
if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Unhandled: ${req.method} ${req.url}` } }));
return;
}
const behavior = this.behaviors.get(token);
if (!behavior) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Unknown token: ${token || "missing"}` } }));
return;
}
behavior.hits += 1;
behavior.startedAt.push(Date.now());
behavior.bodies.push(parsedBody as Record<string, unknown>);
const planned = behavior.queue.shift() || behavior.defaultResponse;
process.stderr.write(
`[MOCK] ${token.slice(0, 8)} hit=${behavior.hits} status=${planned.status}\n`
);
if (planned.delayMs && planned.delayMs > 0) {
await new Promise((r) => setTimeout(r, planned.delayMs));
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(planned.headers || {}),
};
res.writeHead(planned.status, headers);
res.end(JSON.stringify(planned.body));
}
}