mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(playground): add improve-prompt route
POST /api/playground/improve-prompt: validates body via ImprovePromptRequestSchema,
calls /v1/chat/completions internally with the user-chosen model (D8), parses
improved content via parseImprovedContent, returns { improvedSystem?, improvedPrompt?,
tokensIn, tokensOut }. Auth optional (REQUIRE_API_KEY gate). All errors route
through buildErrorBody/sanitizeErrorMessage (Hard Rule #12).
This commit is contained in:
151
src/app/api/playground/improve-prompt/route.ts
Normal file
151
src/app/api/playground/improve-prompt/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* POST /api/playground/improve-prompt
|
||||
*
|
||||
* Improves a user-supplied system prompt and/or user prompt by calling
|
||||
* /v1/chat/completions internally with a meta-prompt (D8 — uses the model
|
||||
* chosen by the user, never forces a cheap model).
|
||||
*
|
||||
* Request: ImprovePromptRequestSchema { system?, prompt?, model, tone? }
|
||||
* Response: { improvedSystem?, improvedPrompt?, tokensIn, tokensOut }
|
||||
*
|
||||
* Auth: optional (mirrors /v1/web/fetch pattern — required only when
|
||||
* REQUIRE_API_KEY=true; always validated when supplied).
|
||||
*
|
||||
* Hard Rule #12: ALL error paths route through buildErrorBody.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
ImprovePromptRequestSchema,
|
||||
buildImproveChatBody,
|
||||
parseImprovedContent,
|
||||
} from "@/lib/playground/promptImprover";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string, upstreamDetails?: unknown): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message), upstreamDetails)),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// 1. Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// 2. Validate via Zod (Hard Rule #7)
|
||||
const parsed = ImprovePromptRequestSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// 3. Optional auth (mirrors /v1/web/fetch pattern)
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
|
||||
// 4. Build the meta-prompt chat body
|
||||
const chatBody = buildImproveChatBody(body);
|
||||
|
||||
// 5. Call /v1/chat/completions on ourselves (D8)
|
||||
const port = process.env.PORT ?? "20128";
|
||||
const baseUrl =
|
||||
process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const upstreamUrl = `${baseUrl}/v1/chat/completions`;
|
||||
|
||||
let upstreamResponse: Response;
|
||||
try {
|
||||
upstreamResponse = await fetch(upstreamUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKeyRaw ?? ""}`,
|
||||
},
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to reach upstream"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, `Improve-prompt failed: ${safeMsg}`);
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
let upstreamText = "";
|
||||
try {
|
||||
upstreamText = await upstreamResponse.text();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return errorResp(
|
||||
upstreamResponse.status >= 400 && upstreamResponse.status < 600
|
||||
? upstreamResponse.status
|
||||
: HTTP_STATUS.BAD_GATEWAY,
|
||||
"Improve-prompt upstream request failed",
|
||||
sanitizeErrorMessage(upstreamText)
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Parse response
|
||||
let data: {
|
||||
choices?: Array<{ message?: { content?: string | null } }>;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
try {
|
||||
data = (await upstreamResponse.json()) as typeof data;
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON from upstream improve-prompt call");
|
||||
}
|
||||
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string") {
|
||||
return errorResp(HTTP_STATUS.BAD_GATEWAY, "Empty or missing content from upstream");
|
||||
}
|
||||
|
||||
// 7. Parse improved content back into system/prompt parts
|
||||
const hadSystem = Boolean(body.system?.trim());
|
||||
const hadPrompt = Boolean(body.prompt?.trim());
|
||||
const improved = parseImprovedContent(content, hadSystem, hadPrompt);
|
||||
|
||||
const tokensIn = data.usage?.prompt_tokens ?? 0;
|
||||
const tokensOut = data.usage?.completion_tokens ?? 0;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...improved,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
189
src/app/api/playground/presets/[id]/route.ts
Normal file
189
src/app/api/playground/presets/[id]/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* GET /api/playground/presets/[id] — get a single preset
|
||||
* PUT /api/playground/presets/[id] — update a preset (partial patch)
|
||||
* DELETE /api/playground/presets/[id] — delete a preset (204 on success)
|
||||
*
|
||||
* Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true).
|
||||
* Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL).
|
||||
* Hard Rule #7: PUT body validated via PlaygroundPresetUpdateSchema.
|
||||
* Hard Rule #12: all error paths via buildErrorBody.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import {
|
||||
getPlaygroundPreset,
|
||||
updatePlaygroundPreset,
|
||||
deletePlaygroundPreset,
|
||||
} from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetUpdateSchema } from "@/shared/schemas/playground";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateId(id: unknown): { valid: true; id: string } | { valid: false; error: Response } {
|
||||
const result = z.string().uuid().safeParse(id);
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false,
|
||||
error: errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid preset id: must be a valid UUID"),
|
||||
};
|
||||
}
|
||||
return { valid: true, id: result.data };
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/playground/presets/[id]
|
||||
* Returns the preset or 404.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
try {
|
||||
const preset = getPlaygroundPreset(id);
|
||||
if (!preset) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
return new Response(JSON.stringify(preset), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to fetch preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/playground/presets/[id]
|
||||
* Body: PlaygroundPresetUpdateSchema (partial)
|
||||
* Returns the updated preset or 404.
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
// Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// Validate body (Hard Rule #7)
|
||||
const parsed = PlaygroundPresetUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const patch = parsed.data;
|
||||
|
||||
try {
|
||||
const updated = updatePlaygroundPreset(id, {
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
...(patch.endpoint !== undefined ? { endpoint: patch.endpoint } : {}),
|
||||
...(patch.model !== undefined ? { model: patch.model } : {}),
|
||||
...("system" in patch ? { system: patch.system ?? null } : {}),
|
||||
...(patch.params !== undefined ? { params: patch.params } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to update preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/playground/presets/[id]
|
||||
* Returns 204 on success; 404 if not found.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id: rawId } = await params;
|
||||
const validation = validateId(rawId);
|
||||
if (!validation.valid) return validation.error;
|
||||
const { id } = validation;
|
||||
|
||||
try {
|
||||
const deleted = deletePlaygroundPreset(id);
|
||||
if (!deleted) {
|
||||
return errorResp(HTTP_STATUS.NOT_FOUND, `Preset not found: ${id}`);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to delete preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
116
src/app/api/playground/presets/route.ts
Normal file
116
src/app/api/playground/presets/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* GET /api/playground/presets — list presets
|
||||
* POST /api/playground/presets — create a preset
|
||||
*
|
||||
* Auth: optional (extractApiKey + isValidApiKey; required when REQUIRE_API_KEY=true).
|
||||
* Hard Rule #5: all DB access via src/lib/db/playgroundPresets (never raw SQL).
|
||||
* Hard Rule #12: all error paths via buildErrorBody.
|
||||
*/
|
||||
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { listPlaygroundPresets, createPlaygroundPreset } from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetCreateSchema } from "@/shared/schemas/playground";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function OPTIONS(): Promise<Response> {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/playground/presets
|
||||
* Returns { presets: PlaygroundPresetListItem[] }
|
||||
*/
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const presets = listPlaygroundPresets();
|
||||
return new Response(JSON.stringify({ presets }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to list presets"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/playground/presets
|
||||
* Body: PlaygroundPresetCreateSchema
|
||||
* Returns the created preset with status 201.
|
||||
*/
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const authError = await checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// 1. Parse JSON body
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// 2. Validate (Hard Rule #7)
|
||||
const parsed = PlaygroundPresetCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
const message = firstIssue
|
||||
? `${firstIssue.path.join(".") || "body"}: ${firstIssue.message}`
|
||||
: "Invalid request body";
|
||||
return errorResp(HTTP_STATUS.BAD_REQUEST, message);
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// 3. Create preset (Hard Rule #5 — via DB module)
|
||||
try {
|
||||
const created = createPlaygroundPreset({
|
||||
name: body.name,
|
||||
endpoint: body.endpoint,
|
||||
model: body.model,
|
||||
system: body.system ?? null,
|
||||
params: body.params,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const safeMsg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to create preset"
|
||||
);
|
||||
return errorResp(HTTP_STATUS.SERVER_ERROR, safeMsg);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user