376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
// SPEC: issue-1190 fake Responses provider P0/P1.
|
|
// Responsibility: deterministic OpenAI-compatible Responses HTTP provider for HWLAB sentinel ECHO runs.
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
export interface FakeResponsesProviderConfig {
|
|
readonly serviceName: string;
|
|
readonly version: string;
|
|
readonly modelId: string;
|
|
readonly mode: "echo";
|
|
readonly host: string;
|
|
readonly port: number;
|
|
readonly responseDelayMs: number;
|
|
readonly nonEchoPolicy: "error";
|
|
}
|
|
|
|
export interface FakeResponsesProviderService {
|
|
readonly config: FakeResponsesProviderConfig;
|
|
fetch(request: Request): Promise<Response>;
|
|
health(): Record<string, unknown>;
|
|
}
|
|
|
|
interface ResponseRequestContext {
|
|
readonly requestId: string;
|
|
readonly traceId: string | null;
|
|
readonly bodyBytes: number;
|
|
readonly bodySha256: string;
|
|
readonly model: string;
|
|
readonly stream: boolean;
|
|
readonly prompt: string | null;
|
|
}
|
|
|
|
const DEFAULT_VERSION = "issue-1190-p1";
|
|
|
|
export function loadFakeResponsesProviderConfig(env: NodeJS.ProcessEnv = process.env): FakeResponsesProviderConfig {
|
|
const modelId = env.FAKE_RESPONSES_MODEL_ID || env.MODEL_ID || "fake-echo";
|
|
const host = env.LISTEN_HOST || env.HOST || "0.0.0.0";
|
|
const port = boundedPort(env.PORT, 8080);
|
|
const responseDelayMs = boundedInteger(env.FAKE_RESPONSES_DELAY_MS || env.RESPONSE_DELAY_MS, 0, 0, 30_000, "response delay");
|
|
return {
|
|
serviceName: env.FAKE_RESPONSES_SERVICE_NAME || "hwlab-fake-responses-provider",
|
|
version: env.FAKE_RESPONSES_VERSION || DEFAULT_VERSION,
|
|
modelId,
|
|
mode: "echo",
|
|
host,
|
|
port,
|
|
responseDelayMs,
|
|
nonEchoPolicy: "error",
|
|
};
|
|
}
|
|
|
|
export function createFakeResponsesProviderService(config: FakeResponsesProviderConfig): FakeResponsesProviderService {
|
|
const service: FakeResponsesProviderService = {
|
|
config,
|
|
async fetch(request: Request): Promise<Response> {
|
|
const url = new URL(request.url);
|
|
if (request.method === "GET" && url.pathname === "/healthz") return jsonResponse(service.health());
|
|
if (request.method === "GET" && url.pathname === "/v1/models") return jsonResponse(modelsPayload(config));
|
|
if (request.method === "POST" && url.pathname === "/v1/responses") return await handleResponses(config, request);
|
|
return jsonResponse({ error: { type: "not_found", message: "not found" }, valuesPrinted: false }, 404);
|
|
},
|
|
health(): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
service: config.serviceName,
|
|
version: config.version,
|
|
mode: config.mode,
|
|
model: config.modelId,
|
|
valuesPrinted: false,
|
|
};
|
|
},
|
|
};
|
|
return service;
|
|
}
|
|
|
|
async function handleResponses(config: FakeResponsesProviderConfig, request: Request): Promise<Response> {
|
|
const requestId = request.headers.get("x-request-id") || `fake_${randomUUID()}`;
|
|
const traceId = request.headers.get("traceparent") || request.headers.get("x-trace-id");
|
|
const rawBody = await request.text();
|
|
const bodySha256 = sha256(rawBody);
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
const parsed = JSON.parse(rawBody) as unknown;
|
|
if (!isRecord(parsed)) throw new Error("body must be a JSON object");
|
|
body = parsed;
|
|
} catch (error) {
|
|
logRequest({ requestId, traceId, bodyBytes: Buffer.byteLength(rawBody), bodySha256, model: config.modelId, stream: false, prompt: null }, 400, "invalid-json");
|
|
return providerError("invalid_json", error instanceof Error ? error.message : String(error), requestId, 400);
|
|
}
|
|
|
|
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : config.modelId;
|
|
const stream = body.stream !== false;
|
|
const prompt = latestUserText(body);
|
|
const context: ResponseRequestContext = {
|
|
requestId,
|
|
traceId,
|
|
bodyBytes: Buffer.byteLength(rawBody),
|
|
bodySha256,
|
|
model,
|
|
stream,
|
|
prompt,
|
|
};
|
|
const echo = parseEcho(prompt);
|
|
if (echo === null) {
|
|
logRequest(context, 400, "non-echo-prompt");
|
|
return providerError("non_echo_prompt", "fake-echo only accepts latest user input matching /^ECHO\\s+([\\s\\S]*)$/", requestId, 400);
|
|
}
|
|
logRequest(context, 200, "echo");
|
|
return stream ? streamResponse(config, context, echo) : jsonResponse(completedResponsePayload(context, echo));
|
|
}
|
|
|
|
function streamResponse(config: FakeResponsesProviderConfig, context: ResponseRequestContext, text: string): Response {
|
|
const responseId = responseIdFor(context);
|
|
const encoder = new TextEncoder();
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
const enqueue = (chunk: string) => controller.enqueue(encoder.encode(chunk));
|
|
enqueue(sse("response.created", responseCreatedPayload(context, responseId)));
|
|
enqueue(sse("response.output_item.added", outputItemAddedPayload(responseId)));
|
|
enqueue(sse("response.content_part.added", contentPartAddedPayload(responseId)));
|
|
if (config.responseDelayMs > 0) await sleep(config.responseDelayMs);
|
|
enqueue(sse("response.output_text.delta", outputTextDeltaPayload(responseId, text)));
|
|
enqueue(sse("response.output_text.done", outputTextDonePayload(responseId, text)));
|
|
enqueue(sse("response.content_part.done", contentPartDonePayload(responseId, text)));
|
|
enqueue(sse("response.output_item.done", outputItemDonePayload(responseId, text)));
|
|
enqueue(sse("response.completed", { type: "response.completed", response: completedResponsePayload(context, text, responseId) }));
|
|
enqueue("data: [DONE]\n\n");
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(body, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache, no-transform",
|
|
"x-request-id": context.requestId,
|
|
},
|
|
});
|
|
}
|
|
|
|
function modelsPayload(config: FakeResponsesProviderConfig): Record<string, unknown> {
|
|
return {
|
|
object: "list",
|
|
data: [{
|
|
id: config.modelId,
|
|
object: "model",
|
|
created: 0,
|
|
owned_by: "unidesk-fake-provider",
|
|
}],
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function responseCreatedPayload(context: ResponseRequestContext, responseId: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.created",
|
|
response: {
|
|
id: responseId,
|
|
object: "response",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
status: "in_progress",
|
|
model: context.model,
|
|
output: [],
|
|
parallel_tool_calls: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function outputItemAddedPayload(responseId: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.output_item.added",
|
|
response_id: responseId,
|
|
output_index: 0,
|
|
item: { id: "msg_echo_0", type: "message", status: "in_progress", role: "assistant", content: [] },
|
|
};
|
|
}
|
|
|
|
function contentPartAddedPayload(responseId: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.content_part.added",
|
|
response_id: responseId,
|
|
item_id: "msg_echo_0",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
part: { type: "output_text", text: "" },
|
|
};
|
|
}
|
|
|
|
function outputTextDeltaPayload(responseId: string, text: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.output_text.delta",
|
|
response_id: responseId,
|
|
item_id: "msg_echo_0",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
delta: text,
|
|
};
|
|
}
|
|
|
|
function outputTextDonePayload(responseId: string, text: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.output_text.done",
|
|
response_id: responseId,
|
|
item_id: "msg_echo_0",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
text,
|
|
};
|
|
}
|
|
|
|
function contentPartDonePayload(responseId: string, text: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.content_part.done",
|
|
response_id: responseId,
|
|
item_id: "msg_echo_0",
|
|
output_index: 0,
|
|
content_index: 0,
|
|
part: { type: "output_text", text },
|
|
};
|
|
}
|
|
|
|
function outputItemDonePayload(responseId: string, text: string): Record<string, unknown> {
|
|
return {
|
|
type: "response.output_item.done",
|
|
response_id: responseId,
|
|
output_index: 0,
|
|
item: assistantMessage(text, "completed"),
|
|
};
|
|
}
|
|
|
|
function completedResponsePayload(context: ResponseRequestContext, text: string, explicitId?: string): Record<string, unknown> {
|
|
const id = explicitId ?? responseIdFor(context);
|
|
return {
|
|
id,
|
|
object: "response",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
status: "completed",
|
|
model: context.model,
|
|
output: [assistantMessage(text, "completed")],
|
|
output_text: text,
|
|
usage: usageFor(context.prompt ?? "", text),
|
|
parallel_tool_calls: false,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function assistantMessage(text: string, status: "in_progress" | "completed"): Record<string, unknown> {
|
|
return {
|
|
id: "msg_echo_0",
|
|
type: "message",
|
|
status,
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text }],
|
|
};
|
|
}
|
|
|
|
function usageFor(prompt: string, output: string): Record<string, unknown> {
|
|
return {
|
|
input_tokens: Math.max(1, Math.ceil(prompt.length / 4)),
|
|
output_tokens: Math.max(1, Math.ceil(output.length / 4)),
|
|
total_tokens: Math.max(2, Math.ceil((prompt.length + output.length) / 4)),
|
|
};
|
|
}
|
|
|
|
function providerError(code: string, message: string, requestId: string, status: number): Response {
|
|
return jsonResponse({
|
|
error: {
|
|
type: "invalid_request_error",
|
|
code,
|
|
message,
|
|
},
|
|
requestId,
|
|
valuesPrinted: false,
|
|
}, status, { "x-request-id": requestId });
|
|
}
|
|
|
|
function latestUserText(body: Record<string, unknown>): string | null {
|
|
const inputText = latestUserTextFromInput(body.input);
|
|
if (inputText !== null) return inputText;
|
|
return latestUserTextFromInput(body.messages);
|
|
}
|
|
|
|
function latestUserTextFromInput(input: unknown): string | null {
|
|
if (typeof input === "string") return input;
|
|
if (!Array.isArray(input)) return null;
|
|
for (let index = input.length - 1; index >= 0; index -= 1) {
|
|
const item = input[index];
|
|
if (typeof item === "string") return item;
|
|
if (!isRecord(item)) continue;
|
|
const role = typeof item.role === "string" ? item.role : null;
|
|
if (role !== null && role !== "user") continue;
|
|
const direct = contentText(item.content);
|
|
if (direct !== null) return direct;
|
|
const text = contentText(item);
|
|
if (text !== null) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function contentText(value: unknown): string | null {
|
|
if (typeof value === "string") return value;
|
|
if (Array.isArray(value)) {
|
|
const parts = value.map(contentText).filter((item): item is string => item !== null);
|
|
return parts.length === 0 ? null : parts.join("");
|
|
}
|
|
if (!isRecord(value)) return null;
|
|
for (const key of ["text", "input_text"]) {
|
|
const found = value[key];
|
|
if (typeof found === "string") return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseEcho(prompt: string | null): string | null {
|
|
if (prompt === null) return null;
|
|
const match = /^ECHO\s+([\s\S]*)$/u.exec(prompt);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function responseIdFor(context: ResponseRequestContext): string {
|
|
return `resp_fake_${sha256(`${context.requestId}\0${context.bodySha256}`).slice(0, 24)}`;
|
|
}
|
|
|
|
function logRequest(context: ResponseRequestContext, status: number, outcome: string): void {
|
|
const payload = {
|
|
event: "fake-responses-provider.request",
|
|
requestId: context.requestId,
|
|
traceIdPresent: context.traceId !== null,
|
|
status,
|
|
outcome,
|
|
model: context.model,
|
|
stream: context.stream,
|
|
bodyBytes: context.bodyBytes,
|
|
bodySha256: `sha256:${context.bodySha256}`,
|
|
promptSha256: context.prompt === null ? null : `sha256:${sha256(context.prompt)}`,
|
|
valuesPrinted: false,
|
|
};
|
|
console.error(JSON.stringify(payload));
|
|
}
|
|
|
|
function sse(event: string, data: Record<string, unknown>): string {
|
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
}
|
|
|
|
function jsonResponse(payload: Record<string, unknown>, status = 200, extraHeaders: Record<string, string> = {}): Response {
|
|
return new Response(`${JSON.stringify(payload)}\n`, {
|
|
status,
|
|
headers: {
|
|
"content-type": "application/json; charset=utf-8",
|
|
...extraHeaders,
|
|
},
|
|
});
|
|
}
|
|
|
|
function sha256(value: string): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function boundedPort(raw: string | undefined, fallback: number): number {
|
|
return boundedInteger(raw, fallback, 1, 65_535, "port");
|
|
}
|
|
|
|
function boundedInteger(raw: string | undefined, fallback: number, min: number, max: number, label: string): number {
|
|
if (raw === undefined || raw.length === 0) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer between ${min} and ${max}`);
|
|
return value;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|