mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
The provider chokepoint every status and reference generator funnels through ran with no budget accounting at all. Nine generator families egressed behind it — the specialised status cards, the generic metric cards, biomarker cards, the batched assessment, the derived scores, the period narrative, and the off-request Coach memory workers (rolling summary, fact extraction, plan proposals) — and none of that spend landed on the daily ledger or met any ceiling. On an operator-key account it was unmetered operator spend. Reserve and reconcile around the provider call, using the same atomic upsert the Coach and document paths use. The ceiling follows the cost owner: a chain whose primary is the operator's own credential draws the operator ceiling, everything else draws the user-plan ceiling, so a self-hoster on their own key is never rationed by the operator's bill. The status enqueue keyed its de-dupe singleton on (user, metric, locale). The locale on a status read is client-chosen, so each of the six supported locales opened its own slot and one legitimate generation pass could be multiplied into six. Key on (user, metric) and carry the locale in the payload instead. The Coach re-shipped the full snapshot on every turn past the history cap, not just the turn that crosses it, so a long conversation paid the per-turn snapshot cost the snapshot-once model exists to remove. Re-ground on the crossing only, tested as a narrow window rather than an equality because a turn advances the count by one or two depending on whether the reply persisted. Give /api/ai/test a daily ceiling and a ledger entry. Its burst bucket bounded rate but nothing cumulative, and with an empty body it resolves the same chain generation uses — which can be the operator's credential. Correct the memory-refresh worker comment, which asserted a budget check inside the chokepoint that did not exist.
255 lines
9.0 KiB
TypeScript
255 lines
9.0 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 { 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";
|
|
|
|
/**
|
|
* 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";
|
|
|
|
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);
|
|
|
|
// 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 = 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);
|
|
}
|
|
|
|
// 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.",
|
|
};
|
|
}
|