mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
`openapi:check` compares the registry against the committed YAML, so a route that was never registered produced no drift and no failure. These nineteen had shipped without a contract: the health probe, the dashboard analytics aggregate, the profile summary, personal records, the badge grid, the whole environmental-context module, both onboarding writes, the Apple Health import pair, the external medication ingest, the self-context adopt, the AI-provider update and the AI connection probe. Each entry is read off the route rather than inferred from a neighbour, so it carries what a client cannot guess: that `/api/health` answers a bare object and not the envelope, that `/api/analytics` records a score row and mints pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200 with `ok: false` because a 5xx comes back as somebody's HTML error page, that the ingest route replays with 200 and creates with 201, and that a home location re-stamps its effective-from instant on every write. Three route-local request schemas move to `src/lib/validations/` so the published shape and the runtime parse are one object rather than two copies — a route module may only export handlers, which is why they could not be imported where they were. The AI-provider PATCH has no schema to move: it hand-rolls its parse field by field, so its entry says so and spells out that a wrongly-typed key is skipped in silence. The generated YAML is deliberately not in this commit.
241 lines
8.6 KiB
TypeScript
241 lines
8.6 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import {
|
|
resolveProviderForTest,
|
|
AITestConfigError,
|
|
type AITestOverride,
|
|
} from "@/lib/ai/provider";
|
|
import { singleUserTurn } from "@/lib/ai/types";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import {
|
|
buildDateKey,
|
|
reconcileSpend,
|
|
reserveBudget,
|
|
resolveDailyCap,
|
|
} from "@/lib/ai/coach/budget";
|
|
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
|
|
|
|
/**
|
|
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
|
* so the ledger reserves what the call can actually spend.
|
|
*/
|
|
const AI_TEST_MAX_TOKENS = 32;
|
|
|
|
/**
|
|
* Daily ceiling on connection tests per user.
|
|
*
|
|
* The 5/min bucket alone bounded burst rate but nothing cumulative: sustained,
|
|
* it permits ~7 200 probes/day, and with an empty body this route resolves the
|
|
* SAME chain generation uses — which can be the operator's own credential. That
|
|
* is unmetered operator spend on a surface that exists to answer "are my
|
|
* settings right?". A person checks that a handful of times after editing a
|
|
* provider; 50 is far above real use and far below anything that matters on the
|
|
* bill.
|
|
*/
|
|
const AI_TEST_DAILY_LIMIT = 50;
|
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
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);
|
|
|
|
// Cumulative ceiling on top of the burst bucket — see AI_TEST_DAILY_LIMIT.
|
|
const daily = await checkRateLimit(
|
|
`ai-test-daily:${user.id}`,
|
|
AI_TEST_DAILY_LIMIT,
|
|
ONE_DAY_MS,
|
|
);
|
|
if (!daily.allowed) {
|
|
annotate({ action: { name: "ai.test.daily_limit" } });
|
|
return apiError("Too many test requests today", 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, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (error) return error;
|
|
const parsed = aiTestOverrideSchema.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);
|
|
}
|
|
|
|
// Meter the probe on the same daily ledger every other AI surface writes to,
|
|
// so operator-key spend from this route is visible rather than invisible.
|
|
// `admin-key` is the operator's own credential and draws the operator
|
|
// ceiling; an override names the caller's own key, and every other resolved
|
|
// type is the user's own egress, so those draw the user-plan ceiling.
|
|
const dateKey = buildDateKey();
|
|
const reservation = await reserveBudget(
|
|
user.id,
|
|
AI_TEST_MAX_TOKENS,
|
|
dateKey,
|
|
resolveDailyCap([
|
|
{
|
|
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
|
|
},
|
|
]),
|
|
);
|
|
if (!reservation.allowed) {
|
|
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
|
return apiError("Daily AI budget exhausted", 429);
|
|
}
|
|
|
|
try {
|
|
const result = await provider.generateCompletion(
|
|
singleUserTurn({
|
|
system: "You are a connection-test responder.",
|
|
user: 'Reply with the JSON object {"ok": true} and nothing else.',
|
|
temperature: 0,
|
|
maxTokens: AI_TEST_MAX_TOKENS,
|
|
// The probe asks for a JSON object — keep the OpenAI / Codex strict
|
|
// JSON mode it relied on before the response_format gate landed.
|
|
responseFormat: "json",
|
|
}),
|
|
);
|
|
|
|
await reconcileSpend(
|
|
user.id,
|
|
reservation.reserved,
|
|
result.tokensUsed ?? reservation.reserved,
|
|
dateKey,
|
|
);
|
|
|
|
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 reason.
|
|
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
|
// A failed probe produced no reported usage — refund the reservation so a
|
|
// provider that is simply misconfigured doesn't burn the caller's ledger
|
|
// while they fix it.
|
|
await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
|
|
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,
|
|
},
|
|
});
|
|
// This route MUST NEVER return a 5xx. A 5xx origin response is
|
|
// rewritten by Cloudflare / the reverse proxy to its own HTML error
|
|
// page, so the browser's `res.json()` crashes with
|
|
// `Unexpected token '<', "<!DOCTYPE "` (Safari: "The string did not
|
|
// match the expected pattern"). That is exactly the failure an
|
|
// operator hit re-authenticating a provider whose token came back as
|
|
// an invalidated-session 500. We always reply 200 with a categorised,
|
|
// secret-free `{ ok:false, reason }` the client can show verbatim.
|
|
const { reasonCode, reason } = classifyTestFailure(err);
|
|
return apiSuccess({
|
|
ok: false,
|
|
providerType: provider.type,
|
|
reasonCode,
|
|
reason,
|
|
// v1.28.28 (#470) — the upstream status (secret-free) so the client
|
|
// can localise "rejected the request (HTTP 400)" with the real number.
|
|
httpStatus: err.httpStatus ?? null,
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Stable, machine-readable failure categories. The client maps each to a
|
|
* localised string; the English `reason` stays as a fallback for any
|
|
* legacy / unmapped code. Secret-free by construction.
|
|
*/
|
|
type TestFailureCode =
|
|
| "credentials"
|
|
| "rate_limited"
|
|
| "server_error"
|
|
| "bad_request"
|
|
| "unreachable";
|
|
|
|
/**
|
|
* Map an upstream provider failure to a human-readable, secret-free
|
|
* reason string. Distinguishes credential/auth, rate-limit, provider
|
|
* server-error and network/timeout classes. A 5xx whose body carries an
|
|
* auth signal (invalidated session token, expired key, "sign in again")
|
|
* is reclassified as a credential failure — that is the shape the
|
|
* operator's re-auth produced, where the gateway answered 500 instead of
|
|
* the expected 401.
|
|
*/
|
|
function classifyTestFailure(
|
|
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
|
): { reasonCode: TestFailureCode; reason: string } {
|
|
const status = err.httpStatus ?? 0;
|
|
const haystack = `${err.message} ${err.bodyExcerpt ?? ""}`;
|
|
const looksLikeAuth =
|
|
/authentication|invalidated|expired|sign in again|api key/i.test(haystack);
|
|
|
|
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
|
|
return {
|
|
reasonCode: "credentials",
|
|
reason:
|
|
"Provider rejected the credentials — re-authenticate in AI settings.",
|
|
};
|
|
}
|
|
if (status === 429) {
|
|
return {
|
|
reasonCode: "rate_limited",
|
|
reason: "Provider rate-limited the request — try again shortly.",
|
|
};
|
|
}
|
|
if (status >= 500) {
|
|
return {
|
|
reasonCode: "server_error",
|
|
reason: "The AI provider returned a server error.",
|
|
};
|
|
}
|
|
// v1.28.28 (#470) — any other 4xx (400 / 404 / 405 / 415 / 422, …) means
|
|
// the endpoint ANSWERED and rejected the request shape or model name. The
|
|
// old lump into "unreachable" sent operators debugging connectivity when
|
|
// the fix was the model field or a gateway's parameter strictness.
|
|
if (status >= 400 && status < 500) {
|
|
return {
|
|
reasonCode: "bad_request",
|
|
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
|
|
};
|
|
}
|
|
return {
|
|
reasonCode: "unreachable",
|
|
reason: "Could not reach the AI provider.",
|
|
};
|
|
}
|