176 lines
5.2 KiB
TypeScript
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));
|
|
}
|
|
}
|