mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Marc's v1.4.4 feedback: the tiles look better when they're all exactly the same size and the strip begins and ends at the same x-coordinate as the charts — the v1.4.4 `flex-1 basis-0` distribution was close but not quite symmetric, and at 6 tiles the right edge of the strip didn't line up with the charts below. Switched to the v1.3-era pattern but with the modern `auto-fit + minmax(9rem, 1fr)` form so it doesn't need explicit breakpoints: grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.75rem; CSS Grid puts every tile in an identical 1fr track. Width is literally identical, gaps are symmetric, and the strip is bounded by the same parent box as the charts so left/right edges align by construction. When the row no longer fits a 9 rem floor the grid wraps to a new row — Marc explicitly tested both and prefers the wrap over the v1.4.x horizontal-scroll for the symmetry it preserves. Tests + typecheck green (671/671). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { z } from "zod/v4";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import {
|
|
resolveProviderForTest,
|
|
AITestConfigError,
|
|
type AITestOverride,
|
|
} from "@/lib/ai/provider";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const overrideSchema = z
|
|
.object({
|
|
provider: z
|
|
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
|
.optional()
|
|
.nullable(),
|
|
model: z.string().min(1).max(120).optional().nullable(),
|
|
baseUrl: z.string().url().max(2048).optional().nullable(),
|
|
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
|
localKey: z.string().min(1).max(500).optional().nullable(),
|
|
openaiKey: z.string().min(1).max(500).optional().nullable(),
|
|
})
|
|
.strict();
|
|
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "ai.test" } });
|
|
|
|
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
|
if (!rl.allowed) return apiError("Too many test requests", 429);
|
|
|
|
// Body is optional. Empty body → behaves like before (test the saved
|
|
// config). Non-empty body → tests the unsaved selection without
|
|
// mutating the user row. Plaintext keys never persist.
|
|
let override: AITestOverride = {};
|
|
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
|
if (contentLength > 0) {
|
|
const { data, error } = await safeJson<unknown>(request);
|
|
if (error) return error;
|
|
const parsed = overrideSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
return apiError("Invalid override payload", 422);
|
|
}
|
|
override = parsed.data;
|
|
}
|
|
|
|
let provider;
|
|
try {
|
|
provider = await resolveProviderForTest(user.id, override);
|
|
} catch (e) {
|
|
if (e instanceof AITestConfigError) {
|
|
return apiError(e.message, e.status);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
if (provider.type === "none") {
|
|
return apiError("No AI provider configured", 422);
|
|
}
|
|
|
|
try {
|
|
const result = await provider.generateCompletion({
|
|
systemPrompt: "You are a connection-test responder.",
|
|
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
|
|
temperature: 0,
|
|
maxTokens: 32,
|
|
});
|
|
|
|
return apiSuccess({
|
|
ok: true,
|
|
providerType: result.providerType,
|
|
model: result.model,
|
|
tokensUsed: result.tokensUsed,
|
|
sample: result.content.slice(0, 200),
|
|
});
|
|
} catch (e) {
|
|
// V3 audit: do not return provider error message + bodyExcerpt to the
|
|
// client (leaks provider URL / partial keys / internal headers).
|
|
// Log full details server-side for the operator and respond with a
|
|
// categorised, generic message.
|
|
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
|
annotate({
|
|
meta: {
|
|
ai_test_error: err.message.slice(0, 500),
|
|
ai_test_status: err.httpStatus ?? null,
|
|
ai_test_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
|
ai_test_provider: provider.type,
|
|
},
|
|
});
|
|
// Map upstream status to a *client-side* status code that won't be
|
|
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
|
|
// the body for its own HTML error page — `await res.json()` in the
|
|
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
|
|
// which is exactly how Marc encountered this when typing a bad
|
|
// OpenAI key. 4xx codes pass through untouched.
|
|
const status = err.httpStatus ?? 0;
|
|
let outboundStatus: number;
|
|
let safeMessage: string;
|
|
if (status === 401 || status === 403) {
|
|
outboundStatus = 422;
|
|
safeMessage = "Provider rejected the credentials";
|
|
} else if (status === 429) {
|
|
outboundStatus = 429;
|
|
safeMessage = "Provider rate-limited the request";
|
|
} else if (status >= 500) {
|
|
outboundStatus = 502;
|
|
safeMessage = "Provider returned a server error";
|
|
} else {
|
|
outboundStatus = 422;
|
|
safeMessage = "Provider connection failed";
|
|
}
|
|
return apiError(safeMessage, outboundStatus);
|
|
}
|
|
});
|