diff --git a/src/app/api/playground/improve-prompt/route.ts b/src/app/api/playground/improve-prompt/route.ts new file mode 100644 index 0000000000..a0efd85d67 --- /dev/null +++ b/src/app/api/playground/improve-prompt/route.ts @@ -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 { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function POST(request: Request): Promise { + // 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 }, + } + ); +} diff --git a/src/app/api/playground/presets/[id]/route.ts b/src/app/api/playground/presets/[id]/route.ts new file mode 100644 index 0000000000..ceabc40429 --- /dev/null +++ b/src/app/api/playground/presets/[id]/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/app/api/playground/presets/route.ts b/src/app/api/playground/presets/route.ts new file mode 100644 index 0000000000..37b2b2b17d --- /dev/null +++ b/src/app/api/playground/presets/route.ts @@ -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 { + 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 { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /api/playground/presets + * Returns { presets: PlaygroundPresetListItem[] } + */ +export async function GET(request: Request): Promise { + 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 { + 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); + } +}