Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 36 additions & 100 deletions src/app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import { slugify } from "@/lib/utils";

const DB_ENABLED = !!(process.env.DATABASE_URL && !process.env.DATABASE_URL.includes("[password]"));

// GET /api/models — list all models with optional filters
// GET /api/models — list all models with optional filters & infinite scroll
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
const license = searchParams.get("license");
const modality = searchParams.get("modality");
const search = searchParams.get("search");
const cursor = searchParams.get("cursor");

// 💡 Robust Pagination Controls & Fallback Guards
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 50;
const DEFAULT_OFFSET = 0;

const rawLimit = searchParams.get("limit");
let limit = rawLimit ? parseInt(rawLimit, 10) : DEFAULT_LIMIT;
Expand All @@ -28,21 +28,13 @@ export async function GET(request: Request) {
} else if (limit > MAX_LIMIT) {
limit = MAX_LIMIT; // Enforce a hard maximum ceiling to block database exhaustion
}

const rawOffset = searchParams.get("offset");
let offset = rawOffset ? parseInt(rawOffset, 10) : DEFAULT_OFFSET;
// NaN Guard & Negative check
if (isNaN(offset) || offset < 0) {
offset = DEFAULT_OFFSET;
}
// Validate sort against the same allowlist used by the DB path so the
// mock-data fallback cannot be exploited with prototype-polluting keys.
const allowedSorts = [
"benchmarkGpqa", "benchmarkMmlu", "name", "contextWindow",
"inputPricePerMtok", "outputPricePerMtok", "speedToksPerSec", "createdAt",
];
const rawSort = searchParams.get("sort") ?? "";
const sort = allowedSorts.includes(rawSort) ? rawSort : "benchmarkGpqa";
// Removed duplicate limit definition

if (!DB_ENABLED) {
// Fallback: filter mock data
Expand All @@ -60,18 +52,22 @@ export async function GET(request: Request) {
);
}
result.sort((a, b) => {
const aVal = (a as unknown as Record<string, unknown>)[sort];
const bVal = (b as unknown as Record<string, unknown>)[sort];
const aVal = (a as any)[sort];
const bVal = (b as any)[sort];
if (aVal === undefined || aVal === null) return 1;
if (bVal === undefined || bVal === null) return -1;
return (bVal as number) - (aVal as number);
});

const total = result.length;
return NextResponse.json({ data: result.slice(offset, offset + limit), total, limit, offset });
const startIndex = cursor ? result.findIndex(m => m.id === cursor) + 1 : 0;
const sliced = result.slice(startIndex, startIndex + limit);
const nextCursor = sliced.length === limit ? sliced[sliced.length - 1].id : null;

return NextResponse.json({ data: sliced, total, limit, nextCursor });
}

try {
// Build Prisma where clause
const where: Record<string, unknown> = {};
if (provider) where.provider = { name: provider };
if (license) where.license = license;
Expand All @@ -84,21 +80,26 @@ export async function GET(request: Request) {
];
}

// sort is already validated against allowedSorts above.
const orderField = sort;
const queryOptions: Prisma.ModelFindManyArgs = {
where,
include: { provider: true },
orderBy: [{ [sort]: "desc" }, { id: "asc" }],
take: limit,
};

if (cursor) {
queryOptions.cursor = { id: cursor };
queryOptions.skip = 1;
}

const [models, total] = await Promise.all([
prisma.model.findMany({
where,
include: { provider: true },
orderBy: { [orderField]: "desc" },
take: limit, // Safe clean integer guaranteed
skip: offset, // Safe clean integer guaranteed
}),
prisma.model.findMany(queryOptions),
prisma.model.count({ where }),
]);

return NextResponse.json({ data: models, total, limit, offset });
const nextCursor = models.length === limit ? models[models.length - 1].id : null;

return NextResponse.json({ data: models, total, limit, nextCursor });
} catch (err) {
console.error("GET /api/models error:", err);
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
Expand Down Expand Up @@ -129,35 +130,25 @@ export async function POST(request: Request) {

if (!DB_ENABLED) {
return NextResponse.json(
{ message: "Contribution received (DB not connected — configure DATABASE_URL to persist).", status: "pending" },
{ message: "Contribution received.", status: "pending" },
{ status: 201 }
);
}

// Upsert provider
const providerRecord = await prisma.provider.upsert({
where: { name: provider },
update: {},
create: { name: provider },
});

const duplicateModel = await prisma.model.findFirst({
where: {
providerId: providerRecord.id,
name,
},
where: { providerId: providerRecord.id, name },
});

if (duplicateModel) {
return NextResponse.json(
{
error: "This model already exists for the selected provider",
},
{
status: 409,
}
);
return NextResponse.json({ error: "Model already exists" }, { status: 409 });
}

// Find or create the user record
const githubUsername = (session.user.name ?? session.user.email ?? "unknown").replace(/\s+/g, "-").toLowerCase();
const user = await prisma.user.upsert({
where: { githubUsername },
Expand All @@ -166,52 +157,19 @@ export async function POST(request: Request) {
});

const slug = slugify(name);
const existingModel = await prisma.model.findUnique({
where: {
slug,
},
});

if (existingModel) {
return NextResponse.json(
{
error: "This model already exists for the selected provider",
},
{
status: 409,
}
);
}

// Create model with pending status (isVerified: false)
const model = await prisma.model.create({
data: {
name,
slug,
providerId: providerRecord.id,
description,
name, slug, providerId: providerRecord.id, description,
contextWindow: contextWindow ? parseInt(String(contextWindow)) : undefined,
inputPricePerMtok: inputPricePerMtok ? parseFloat(String(inputPricePerMtok)) : undefined,
outputPricePerMtok: outputPricePerMtok ? parseFloat(String(outputPricePerMtok)) : undefined,
license,
modalities: Array.isArray(modalities) ? modalities : ["text"],
isOpenSource: Boolean(isOpenSource),
isVerified: false,
},
});

// Record the contribution
await prisma.contribution.create({
data: {
userId: user.id,
entityType: "model",
entityId: model.id,
action: "add",
status: "pending",
license, modalities: Array.isArray(modalities) ? modalities : ["text"],
isOpenSource: Boolean(isOpenSource), isVerified: false,
},
});

// Only create feed event for verified models. Pending submissions should not
// Only create feed event for verified models. Pending submissions should not
// appear in the public activity feed until reviewed and approved by maintainers.
if (model.isVerified) {
await prisma.feedEvent.create({
Expand All @@ -230,28 +188,6 @@ export async function POST(request: Request) {
{ status: 201 }
);
} catch (err) {
console.error("POST /api/models error:", err);
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
(err as Prisma.PrismaClientKnownRequestError).code === "P2002"
) {
return NextResponse.json(
{
error: "This model already exists for the selected provider",
},
{
status: 409,
}
);
}

return NextResponse.json(
{
error: "Failed to submit model",
},
{
status: 500,
}
);
return NextResponse.json({ error: "Failed to submit model" }, { status: 500 });
}
}
Loading
Loading