feat: add HWLAB fake echo provider
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user