feat(radar): persist local catalog state

This commit is contained in:
diegosouzapw
2026-08-08 20:02:59 -03:00
parent 59a583ddf6
commit c0bfdba52c
55 changed files with 1847 additions and 230 deletions

View File

@@ -0,0 +1,361 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card } from "@/shared/components";
export interface RadarMergedEntry {
provider: string;
modelId: string;
displayName: string;
monthlyTokens: number;
creditTokens: number;
freeType: string;
poolKey: string | null;
tos: string;
trainsOnPrompts?: boolean;
enabled?: boolean;
origin: "baseline" | "radar" | "local";
disabledBy?: "radar";
contextWindow?: number | null;
capabilities?: { tools: boolean; vision: boolean; thinking: boolean };
budget?: { kind: string; tokensPerMonth?: number; poolId?: string };
limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null };
setup?: { keyUrl: string | null; steps: string[] } | null;
}
interface RadarLocalModelState {
provider: string;
modelId: string;
displayName: string | null;
enabled: boolean | null;
tombstoned: boolean;
updatedAt: string;
}
interface RadarCatalogTableProps {
entries: RadarMergedEntry[];
refreshCatalog: () => Promise<void>;
onError: (message: string) => void;
}
function formatTokens(value: number): string {
if (value === 0) return "rate-only";
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
return String(value);
}
function budgetLabel(entry: RadarMergedEntry): string {
if (entry.budget?.kind === "shared_pool") {
return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`;
}
if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only";
return `${formatTokens(entry.monthlyTokens)}/mo`;
}
export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCatalogTableProps) {
const t = useTranslations("radarPage");
const [states, setStates] = useState<RadarLocalModelState[]>([]);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [displayName, setDisplayName] = useState("");
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const loadState = useCallback(async () => {
try {
const response = await fetch("/api/radar/local-model-state");
if (!response.ok) return;
const payload = await response.json();
setStates(Array.isArray(payload.states) ? payload.states : []);
} catch {
onError(t("errorLoading"));
}
}, [onError, t]);
useEffect(() => {
void loadState();
}, [loadState]);
const stateByKey = useMemo(
() => new Map(states.map((state) => [`${state.provider}:${state.modelId}`, state])),
[states]
);
const hiddenModels = useMemo(() => states.filter((state) => state.tombstoned), [states]);
const applyResponse = useCallback(async (response: Response) => {
if (!response.ok) throw new Error("save_failed");
const payload = await response.json();
setStates(Array.isArray(payload.states) ? payload.states : []);
}, []);
const mutate = useCallback(
async (operation: () => Promise<Response>) => {
setSaving(true);
onError("");
try {
await applyResponse(await operation());
setEditingKey(null);
await refreshCatalog();
} catch {
onError(t("localStateSaveFailed"));
} finally {
setSaving(false);
}
},
[applyResponse, onError, refreshCatalog, t]
);
const beginEdit = useCallback((entry: RadarMergedEntry) => {
setEditingKey(`${entry.provider}:${entry.modelId}`);
setDisplayName(entry.displayName);
setEnabled(entry.enabled !== false);
}, []);
const saveOverride = useCallback(
(entry: RadarMergedEntry) =>
mutate(() =>
fetch("/api/radar/local-model-state", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: entry.provider,
modelId: entry.modelId,
displayName,
enabled,
}),
})
),
[displayName, enabled, mutate]
);
const resetOverride = useCallback(
(entry: Pick<RadarMergedEntry, "provider" | "modelId">) => {
const query = new URLSearchParams({ provider: entry.provider, modelId: entry.modelId });
return mutate(() =>
fetch(`/api/radar/local-model-state?${query.toString()}`, { method: "DELETE" })
);
},
[mutate]
);
const setTombstone = useCallback(
(provider: string, modelId: string, tombstoned: boolean) =>
mutate(() =>
fetch("/api/radar/local-model-state", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, modelId, tombstoned }),
})
),
[mutate]
);
return (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-text-muted border-b border-border">
<th className="pb-3 font-medium">{t("colProvider")}</th>
<th className="pb-3 font-medium">{t("colModel")}</th>
<th className="pb-3 font-medium">{t("colQuota")}</th>
<th className="pb-3 font-medium">{t("colContext")}</th>
<th className="pb-3 font-medium">{t("colCapabilities")}</th>
<th className="pb-3 font-medium">{t("colTos")}</th>
<th className="pb-3 font-medium text-right">{t("colActions")}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const key = `${entry.provider}:${entry.modelId}`;
const localState = stateByKey.get(key);
const hasOverride =
localState && (localState.displayName !== null || localState.enabled !== null);
return (
<tr
key={key}
className={`border-b border-border/50 last:border-b-0 ${
entry.enabled === false ? "opacity-50" : ""
}`}
>
<td className="py-3">
<div className="flex items-center gap-2">
<span className="font-medium">{entry.provider}</span>
{entry.origin === "radar" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400 font-medium">
{t("newBadge")}
</span>
)}
{entry.origin === "local" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">
{t("localBadge")}
</span>
)}
{entry.setup?.keyUrl && (
<Link
href={`/dashboard/radar/setup?provider=${encodeURIComponent(entry.provider)}`}
className="text-xs text-violet-400 hover:underline"
title={t("setupGuide")}
>
</Link>
)}
</div>
{entry.enabled === false && entry.disabledBy === "radar" && (
<p className="text-xs text-red-400 mt-0.5">{t("disabledByFeed")}</p>
)}
</td>
<td className="py-3 text-text-muted text-sm font-mono max-w-[240px]">
{editingKey === key ? (
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
aria-label={t("modelDisplayName")}
maxLength={160}
className="w-full min-w-[180px] px-2 py-1 rounded border border-border bg-transparent text-text-main focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
) : (
<span className="block truncate">{entry.displayName}</span>
)}
</td>
<td className="py-3 text-sm">{budgetLabel(entry)}</td>
<td className="py-3 text-sm text-text-muted">
{entry.contextWindow ? `${(entry.contextWindow / 1000).toFixed(0)}K` : "—"}
</td>
<td className="py-3">
<div className="flex gap-1">
{entry.capabilities?.tools && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
{t("capTools")}
</span>
)}
{entry.capabilities?.vision && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
{t("capVision")}
</span>
)}
{entry.capabilities?.thinking && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400">
{t("capThinking")}
</span>
)}
</div>
</td>
<td className="py-3">
<span
className={`text-xs px-2 py-1 rounded ${
entry.tos === "ok"
? "bg-green-500/10 text-green-400"
: entry.tos === "caution"
? "bg-yellow-500/10 text-yellow-400"
: entry.tos === "avoid"
? "bg-red-500/10 text-red-400"
: "bg-gray-500/10 text-gray-400"
}`}
>
{entry.tos}
</span>
</td>
<td className="py-3 pl-3">
{editingKey === key ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<label className="inline-flex items-center gap-1 text-xs text-text-muted">
<input
type="checkbox"
checked={enabled}
disabled={entry.disabledBy === "radar"}
onChange={(event) => setEnabled(event.target.checked)}
/>
{t("modelEnabled")}
</label>
<button
type="button"
disabled={saving || displayName.trim().length === 0}
onClick={() => void saveOverride(entry)}
className="text-xs text-violet-400 hover:underline disabled:opacity-50"
>
{t("saveModel")}
</button>
<button
type="button"
disabled={saving}
onClick={() => setEditingKey(null)}
className="text-xs text-text-muted hover:text-text-main disabled:opacity-50"
>
{t("cancelEdit")}
</button>
</div>
) : (
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
disabled={saving}
onClick={() => beginEdit(entry)}
className="text-xs text-violet-400 hover:underline disabled:opacity-50"
>
{t("editModel")}
</button>
{hasOverride && (
<button
type="button"
disabled={saving}
onClick={() => void resetOverride(entry)}
className="text-xs text-text-muted hover:text-text-main disabled:opacity-50"
>
{t("resetModel")}
</button>
)}
<button
type="button"
disabled={saving}
onClick={() => void setTombstone(entry.provider, entry.modelId, true)}
className="text-xs text-red-400 hover:underline disabled:opacity-50"
>
{t("hideModel")}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
{hiddenModels.length > 0 && (
<Card>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-semibold">{t("hiddenModelsTitle")}</h3>
{hiddenModels.map((state) => (
<div
key={`${state.provider}:${state.modelId}:hidden`}
className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 pb-3 last:border-b-0 last:pb-0"
>
<div>
<span className="font-medium">{state.provider}</span>
<span className="ml-2 text-sm font-mono text-text-muted">
{state.displayName ?? state.modelId}
</span>
</div>
<button
type="button"
disabled={saving}
onClick={() => void setTombstone(state.provider, state.modelId, false)}
className="text-sm text-violet-400 hover:underline disabled:opacity-50"
>
{t("restoreModel")}
</button>
</div>
))}
</div>
</Card>
)}
</>
);
}

View File

@@ -3,10 +3,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync";
import { isValidSupporterKeyFormat } from "@/lib/radar/supporterKey";
import { RadarCatalogTable, type RadarMergedEntry } from "./RadarCatalogTable";
// ---------------------------------------------------------------------------
// Types
@@ -18,27 +18,6 @@ interface RadarMeta {
fetchedAt: string;
}
interface RadarMergedEntry {
provider: string;
modelId: string;
displayName: string;
monthlyTokens: number;
creditTokens: number;
freeType: string;
poolKey: string | null;
tos: string;
trainsOnPrompts?: boolean;
enabled?: boolean;
origin: "baseline" | "radar" | "local";
disabledBy?: "radar";
// Extended feed fields (present when origin=radar)
contextWindow?: number | null;
capabilities?: { tools: boolean; vision: boolean; thinking: boolean };
budget?: { kind: string; tokensPerMonth?: number; poolId?: string };
limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null };
setup?: { keyUrl: string | null; steps: string[] } | null;
}
type PageState = "flag_off" | "optin_pending" | "empty" | "populated";
/** D28 — referral links / free credits. Client-side mirror of RadarReferral. */
@@ -67,7 +46,7 @@ type RadarTabId = "catalog" | "referrals";
export function resolveRadarPageState(
flagOn: boolean,
optedIn: boolean,
hasEntries: boolean,
hasEntries: boolean
): PageState {
if (!flagOn) return "flag_off";
if (!optedIn) return "optin_pending";
@@ -90,23 +69,6 @@ function relativeTime(isoDate: string): string {
return `${days}d ago`;
}
/** Format token count as human-readable. */
function formatTokens(n: number): string {
if (n === 0) return "rate-only";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return String(n);
}
/** Budget display string. */
function budgetLabel(entry: RadarMergedEntry): string {
if (entry.budget?.kind === "shared_pool") {
return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`;
}
if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only";
return `${formatTokens(entry.monthlyTokens)}/mo`;
}
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
@@ -144,31 +106,34 @@ export default function RadarPage() {
const [hasSupporterKey, setHasSupporterKey] = useState(false);
const [supporterKeyMasked, setSupporterKeyMasked] = useState<string | null>(null);
const [showKeyForm, setShowKeyForm] = useState(false);
// Fetch catalog
const fetchCatalog = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/radar/catalog");
if (res.status === 404) {
// Flag off — treat as not found
setOptIn(false);
setEntries([]);
setMeta(null);
setLoading(false);
return;
const fetchCatalog = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
setError("");
try {
const res = await fetch("/api/radar/catalog");
if (res.status === 404) {
// Flag off — treat as not found
setOptIn(false);
setEntries([]);
setMeta(null);
if (showLoading) setLoading(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data.entries || []);
setMeta(data.meta || null);
} catch (err) {
setError(err instanceof Error ? err.message : t("errorLoading"));
} finally {
if (showLoading) setLoading(false);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEntries(data.entries || []);
setMeta(data.meta || null);
} catch (err) {
setError(err instanceof Error ? err.message : t("errorLoading"));
} finally {
setLoading(false);
}
}, [t]);
},
[t]
);
const refreshCatalogSilently = useCallback(() => fetchCatalog(false), [fetchCatalog]);
// D28 — fetch the referral links section ("Pegue seus créditos grátis").
// Best-effort: flag off => 404, no cache => empty shape; either way this
@@ -204,7 +169,7 @@ export default function RadarPage() {
setOptIn(settingsData.optIn === true);
setHasSupporterKey(settingsData.hasSupporterKey === true);
setSupporterKeyMasked(
typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null,
typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null
);
// F4/T7 — best-effort: keep whatever we already had if the field is
// absent (older cached response shape), never fall back to a literal.
@@ -334,7 +299,7 @@ export default function RadarPage() {
const pageState = resolveRadarPageState(
optIn !== false, // if we got a 404, optIn=false => flag off
optIn === true,
entries.length > 0 && meta !== null,
meta !== null
);
// Flag off — render not-found
@@ -379,9 +344,7 @@ export default function RadarPage() {
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
)}
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
{loading ? (
<div className="flex items-center justify-center min-h-[200px]">
@@ -625,98 +588,11 @@ export default function RadarPage() {
{/* Populated catalog table */}
{pageState === "populated" && activeTab === "catalog" && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-text-muted border-b border-border">
<th className="pb-3 font-medium">{t("colProvider")}</th>
<th className="pb-3 font-medium">{t("colModel")}</th>
<th className="pb-3 font-medium">{t("colQuota")}</th>
<th className="pb-3 font-medium">{t("colContext")}</th>
<th className="pb-3 font-medium">{t("colCapabilities")}</th>
<th className="pb-3 font-medium">{t("colTos")}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={`${entry.provider}:${entry.modelId}`}
className={`border-b border-border/50 last:border-b-0 ${
entry.enabled === false ? "opacity-50" : ""
}`}
>
<td className="py-3">
<div className="flex items-center gap-2">
<span className="font-medium">{entry.provider}</span>
{entry.origin === "radar" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400 font-medium">
{t("newBadge")}
</span>
)}
{entry.setup?.keyUrl && (
<Link
href={`/dashboard/radar/setup?provider=${encodeURIComponent(entry.provider)}`}
className="text-xs text-violet-400 hover:underline"
title={t("setupGuide")}
>
</Link>
)}
</div>
{entry.enabled === false && entry.disabledBy === "radar" && (
<p className="text-xs text-red-400 mt-0.5">{t("disabledByFeed")}</p>
)}
</td>
<td className="py-3 text-text-muted text-sm font-mono truncate max-w-[200px]">
{entry.displayName}
</td>
<td className="py-3 text-sm">{budgetLabel(entry)}</td>
<td className="py-3 text-sm text-text-muted">
{entry.contextWindow
? `${(entry.contextWindow / 1000).toFixed(0)}K`
: "—"}
</td>
<td className="py-3">
<div className="flex gap-1">
{entry.capabilities?.tools && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
{t("capTools")}
</span>
)}
{entry.capabilities?.vision && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
{t("capVision")}
</span>
)}
{entry.capabilities?.thinking && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400">
{t("capThinking")}
</span>
)}
</div>
</td>
<td className="py-3">
<span
className={`text-xs px-2 py-1 rounded ${
entry.tos === "ok"
? "bg-green-500/10 text-green-400"
: entry.tos === "caution"
? "bg-yellow-500/10 text-yellow-400"
: entry.tos === "avoid"
? "bg-red-500/10 text-red-400"
: "bg-gray-500/10 text-gray-400"
}`}
>
{entry.tos}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<RadarCatalogTable
entries={entries}
refreshCatalog={refreshCatalogSilently}
onError={setError}
/>
)}
</>
)}

View File

@@ -0,0 +1,165 @@
/**
* Local-only Radar model overrides and tombstones.
*
* The browser never sends these settings to the private Radar server. The
* route is management-authenticated and exists only while RADAR_ENABLED is on.
*/
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
clearRadarLocalModelOverride,
listRadarLocalModelState,
setRadarLocalModelOverride,
setRadarModelTombstone,
} from "@/lib/db/radar";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const providerSchema = z
.string()
.trim()
.regex(/^[a-z0-9][a-z0-9._-]{0,99}$/i);
const modelIdSchema = z
.string()
.trim()
.min(1)
.max(200)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
const identityShape = { provider: providerSchema, modelId: modelIdSchema };
const overrideSchema = z
.object({
...identityShape,
displayName: z
.string()
.trim()
.min(1)
.max(160)
.refine((value) => !/[\u0000-\u001f\u007f]/.test(value))
.nullable()
.optional(),
enabled: z.boolean().nullable().optional(),
})
.strict()
.refine(
(value) =>
Object.prototype.hasOwnProperty.call(value, "displayName") ||
Object.prototype.hasOwnProperty.call(value, "enabled")
);
const tombstoneSchema = z.object({ ...identityShape, tombstoned: z.boolean() }).strict();
const identitySchema = z.object(identityShape).strict();
function json(body: unknown, status = 200): NextResponse {
return NextResponse.json(body, {
status,
headers: { ...CORS_HEADERS, "Cache-Control": "no-store" },
});
}
function error(status: number, message: string): NextResponse {
return json(buildErrorBody(status, message), status);
}
async function authorize(request: Request): Promise<NextResponse | null> {
if (!isFeatureFlagEnabled("RADAR_ENABLED")) return error(404, "Not found");
if (!(await isAuthenticated(request))) return error(401, "Unauthorized");
return null;
}
async function readJson(request: Request): Promise<unknown | null> {
try {
return await request.json();
} catch {
return null;
}
}
function stateResponse(): NextResponse {
return json({ states: listRadarLocalModelState() });
}
function internalError(cause: unknown): NextResponse {
return error(500, sanitizeErrorMessage(cause) || "Failed to update Radar local state");
}
export async function OPTIONS(): Promise<Response> {
return handleCorsOptions();
}
export async function GET(request: Request): Promise<NextResponse> {
const authError = await authorize(request);
if (authError) return authError;
try {
return stateResponse();
} catch (cause: unknown) {
return internalError(cause);
}
}
export async function PATCH(request: Request): Promise<NextResponse> {
const authError = await authorize(request);
if (authError) return authError;
const parsed = overrideSchema.safeParse(await readJson(request));
if (!parsed.success) return error(400, "Invalid Radar local override");
try {
const { provider, modelId, displayName, enabled } = parsed.data;
const patch: { displayName?: string | null; enabled?: boolean | null } = {};
if (Object.prototype.hasOwnProperty.call(parsed.data, "displayName")) {
patch.displayName = displayName;
}
if (Object.prototype.hasOwnProperty.call(parsed.data, "enabled")) patch.enabled = enabled;
if (!setRadarLocalModelOverride(provider, modelId, patch)) {
return error(400, "Invalid Radar local override");
}
return stateResponse();
} catch (cause: unknown) {
return internalError(cause);
}
}
export async function PUT(request: Request): Promise<NextResponse> {
const authError = await authorize(request);
if (authError) return authError;
const parsed = tombstoneSchema.safeParse(await readJson(request));
if (!parsed.success) return error(400, "Invalid Radar tombstone");
try {
const { provider, modelId, tombstoned } = parsed.data;
if (!setRadarModelTombstone(provider, modelId, tombstoned)) {
return error(400, "Invalid Radar tombstone");
}
return stateResponse();
} catch (cause: unknown) {
return internalError(cause);
}
}
export async function DELETE(request: Request): Promise<NextResponse> {
const authError = await authorize(request);
if (authError) return authError;
const url = new URL(request.url);
const parsed = identitySchema.safeParse({
provider: url.searchParams.get("provider"),
modelId: url.searchParams.get("modelId"),
});
if (!parsed.success) return error(400, "provider and modelId are required");
try {
if (!clearRadarLocalModelOverride(parsed.data.provider, parsed.data.modelId)) {
return error(400, "Invalid Radar local override");
}
return stateResponse();
} catch (cause: unknown) {
return internalError(cause);
}
}