Skip to content

Commit e2bafcd

Browse files
authored
feat: manage import models and recover interrupted runs (#99)
1 parent 282e6c6 commit e2bafcd

45 files changed

Lines changed: 1146 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/.env.example

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ SUPABASE_SECRET_KEY=
2020
# OpenRouter extraction
2121
OPENROUTER_API_KEY=
2222

23-
# Allowed models. Select the active model in admin.
24-
COURSEMAP_OPENROUTER_MODELS=google/gemini-3.1-flash-lite,qwen/qwen3-32b,google/gemini-2.5-flash-lite
23+
# Models and pricing are managed in the admin dashboard and stored in the database.
2524

2625
# Background imports through Vercel Queues
2726
COURSEMAP_QUEUE_IMPORTS_ENABLED=false

apps/web/app/api/admin/academic-structure-imports/route.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { loadImportModelSetting } from "@/lib/admin/settings";
12
import { canManageCatalogueImports } from "@/lib/auth/viewer";
23
import { recordAcademicStructureImportDispatch } from "@/lib/structure-import/import-store";
34
import {
@@ -93,7 +94,11 @@ async function startQueuedImport(request: Request) {
9394

9495
let input;
9596
try {
96-
input = parseAcademicStructureImportRequest(body);
97+
const setting = await loadImportModelSetting();
98+
if (setting.error) return jsonError(setting.error, 503);
99+
input = parseAcademicStructureImportRequest(body, setting.model);
100+
if (!setting.options.includes(input.requestedModel))
101+
return jsonError("Choose an enabled import model.", 400);
97102
} catch (error) {
98103
return jsonError(
99104
error instanceof AcademicStructureImportRequestError
@@ -345,3 +350,29 @@ export async function PATCH(request: Request) {
345350
);
346351
}
347352
}
353+
354+
export async function DELETE(request: Request) {
355+
if (!(await canManageCatalogueImports())) {
356+
return jsonError("Import permission is required.", 403);
357+
}
358+
let runId: string;
359+
try {
360+
runId = parseReconciliationRequest(await request.json());
361+
} catch {
362+
return jsonError(
363+
"A valid academic structure import run ID is required.",
364+
400,
365+
);
366+
}
367+
const supabase = await createClient();
368+
const { error } = await supabase.rpc("cancel_academic_structure_import", {
369+
p_run_id: runId,
370+
});
371+
if (error) {
372+
return jsonError(
373+
"The import run could not be stopped.",
374+
error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 500,
375+
);
376+
}
377+
return Response.json({ runId, status: "stopped" });
378+
}

apps/web/app/api/admin/course-imports/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { loadImportModelSetting } from "@/lib/admin/settings";
12
import { canManageCourseImports } from "@/lib/auth/viewer";
23
import { recordCourseImportDispatch } from "@/lib/course-import/import-store";
34
import {
@@ -74,7 +75,11 @@ async function startQueuedImport(request: Request) {
7475

7576
let input;
7677
try {
77-
input = parseCourseImportRequest(body);
78+
const setting = await loadImportModelSetting();
79+
if (setting.error) return jsonError(setting.error, 503);
80+
input = parseCourseImportRequest(body, setting.model);
81+
if (!setting.options.includes(input.requestedModel))
82+
return jsonError("Choose an enabled import model.", 400);
7883
} catch (error) {
7984
return jsonError(
8085
error instanceof CourseImportRequestError

apps/web/lib/admin/import-model.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export type ImportModel = {
2+
id: string;
3+
name: string;
4+
provider: string;
5+
enabled: boolean;
6+
visible: boolean;
7+
input_usd_per_million: number | null;
8+
output_usd_per_million: number | null;
9+
pricing_updated_at: string | null;
10+
};
11+
12+
export const ESTIMATED_IMPORT_INPUT_TOKENS = 10_000;
13+
export const ESTIMATED_IMPORT_OUTPUT_TOKENS = 2_000;
14+
15+
export function estimatedImportCost(model: ImportModel) {
16+
if (
17+
model.input_usd_per_million === null ||
18+
model.output_usd_per_million === null
19+
)
20+
return null;
21+
return (
22+
(model.input_usd_per_million * ESTIMATED_IMPORT_INPUT_TOKENS +
23+
model.output_usd_per_million * ESTIMATED_IMPORT_OUTPUT_TOKENS) /
24+
1_000_000
25+
);
26+
}
27+
28+
export function formatImportPrice(usd: number | null) {
29+
if (usd === null || !Number.isFinite(usd) || usd < 0) return "—";
30+
if (usd === 0) return "$0";
31+
if (usd < 0.01) {
32+
const cents = usd * 100;
33+
return cents < 0.001 ? "<0.001¢" : `${Number(cents.toFixed(3))}¢`;
34+
}
35+
return `$${usd.toFixed(2)}`;
36+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { assertOpenRouterModel } from "@/lib/course-import/openrouter";
2+
import type { ImportModel } from "@/lib/admin/import-model";
3+
4+
function isRecord(value: unknown): value is Record<string, unknown> {
5+
return typeof value === "object" && value !== null && !Array.isArray(value);
6+
}
7+
8+
function tokenRate(value: unknown) {
9+
if (typeof value !== "string" || !value.trim()) return null;
10+
const rate = Number(value) * 1_000_000;
11+
return Number.isFinite(rate) && rate >= 0 && rate < 1_000_000 ? rate : null;
12+
}
13+
14+
export function readCatalogueModel(
15+
payload: unknown,
16+
requested: string,
17+
): ImportModel {
18+
const id = assertOpenRouterModel(requested);
19+
if (!isRecord(payload) || !Array.isArray(payload.data))
20+
throw new Error("The model catalogue could not be read.");
21+
const model = payload.data.find(
22+
(item: unknown) => isRecord(item) && item.id === id,
23+
);
24+
if (!isRecord(model) || typeof model.name !== "string")
25+
throw new Error("The model was not found in OpenRouter's catalogue.");
26+
const pricing = isRecord(model.pricing) ? model.pricing : {};
27+
const [provider, ...name] = model.name.split(": ");
28+
return {
29+
id,
30+
name: (name.join(": ") || model.name).slice(0, 160),
31+
provider: (name.length ? provider : id.split("/")[0]!).slice(0, 80),
32+
enabled: true,
33+
visible: true,
34+
input_usd_per_million: tokenRate(pricing.prompt),
35+
output_usd_per_million: tokenRate(pricing.completion),
36+
pricing_updated_at: new Date().toISOString(),
37+
};
38+
}
39+
40+
export async function fetchCatalogueModel(id: string) {
41+
assertOpenRouterModel(id);
42+
const response = await fetch("https://openrouter.ai/api/v1/models", {
43+
cache: "no-store",
44+
signal: AbortSignal.timeout(10_000),
45+
});
46+
if (!response.ok)
47+
throw new Error("OpenRouter's model catalogue is unavailable.");
48+
return readCatalogueModel(await response.json(), id);
49+
}

apps/web/lib/admin/settings-actions.ts

Lines changed: 146 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,180 @@
22

33
import { revalidatePath } from "next/cache";
44
import { canManageCourseImports } from "@/lib/auth/viewer";
5-
import { configuredOpenRouterModels } from "@/lib/course-import/openrouter";
65
import { createClient } from "@/lib/supabase/server";
76
import { IMPORT_MODEL_SETTING_KEY } from "@/lib/admin/settings";
7+
import { fetchCatalogueModel } from "@/lib/admin/model-catalogue";
8+
import { assertOpenRouterModel } from "@/lib/course-import/openrouter";
89

910
export type ImportModelActionResult = {
1011
ok: boolean;
1112
model: string;
1213
message: string;
1314
};
1415

15-
/**
16-
* Set the model every subsequent import run requests. Rejecting models the
17-
* deployment does not allow keeps this in step with the queue's own check
18-
* rather than deferring the failure to a run that has already started.
19-
*/
16+
function refreshImportPages() {
17+
revalidatePath("/admin", "layout");
18+
}
19+
2020
export async function setImportModel(
2121
model: string,
2222
): Promise<ImportModelActionResult> {
23-
const requested = model.trim().toLowerCase();
24-
if (!configuredOpenRouterModels().includes(requested)) {
23+
if (!(await canManageCourseImports()))
24+
return {
25+
ok: false,
26+
model,
27+
message: "Import management permission is required.",
28+
};
29+
try {
30+
const requested = assertOpenRouterModel(model);
31+
const supabase = await createClient();
32+
const { error } = await supabase
33+
.from("app_settings")
34+
.upsert(
35+
{ key: IMPORT_MODEL_SETTING_KEY, value: requested },
36+
{ onConflict: "key" },
37+
);
38+
if (error)
39+
return {
40+
ok: false,
41+
model,
42+
message:
43+
"The default model could not be saved. Choose an enabled model.",
44+
};
45+
refreshImportPages();
46+
return {
47+
ok: true,
48+
model: requested,
49+
message: "The default import model was updated.",
50+
};
51+
} catch {
2552
return {
2653
ok: false,
2754
model,
28-
message: "Choose a model this deployment is configured to call.",
55+
message: "The default model could not be saved.",
2956
};
3057
}
58+
}
3159

32-
if (!(await canManageCourseImports())) {
60+
export async function saveImportModel(
61+
model: string,
62+
refreshOnly = false,
63+
): Promise<ImportModelActionResult> {
64+
if (!(await canManageCourseImports()))
3365
return {
3466
ok: false,
3567
model,
3668
message: "Import management permission is required.",
3769
};
70+
try {
71+
const entry = await fetchCatalogueModel(model);
72+
const supabase = await createClient();
73+
const { enabled, visible, ...pricing } = entry;
74+
const { error } = refreshOnly
75+
? await supabase
76+
.from("import_models")
77+
.update(pricing)
78+
.eq("id", entry.id)
79+
.eq("enabled", true)
80+
.select("id")
81+
.single()
82+
: await supabase
83+
.from("import_models")
84+
.upsert({ ...pricing, enabled, visible });
85+
if (error)
86+
return { ok: false, model, message: "The model could not be saved." };
87+
refreshImportPages();
88+
return {
89+
ok: true,
90+
model: entry.id,
91+
message: "The model and its pricing were saved.",
92+
};
93+
} catch {
94+
return {
95+
ok: false,
96+
model,
97+
message:
98+
"The model could not be loaded from OpenRouter. Check its identifier and try again.",
99+
};
38100
}
101+
}
39102

103+
export async function removeImportModel(
104+
model: string,
105+
): Promise<ImportModelActionResult> {
106+
if (!(await canManageCourseImports()))
107+
return {
108+
ok: false,
109+
model,
110+
message: "Import management permission is required.",
111+
};
40112
try {
41113
const supabase = await createClient();
42-
const { error } = await supabase
43-
.from("app_settings")
44-
.upsert(
45-
{ key: IMPORT_MODEL_SETTING_KEY, value: requested },
46-
{ onConflict: "key" },
47-
);
48-
if (error) {
49-
return { ok: false, model, message: "The model could not be saved." };
50-
}
114+
const { data, error } = await supabase
115+
.from("import_models")
116+
.update({ enabled: false })
117+
.eq("id", assertOpenRouterModel(model))
118+
.select("id")
119+
.single();
120+
if (error || !data)
121+
return {
122+
ok: false,
123+
model,
124+
message:
125+
"The model could not be removed. Choose another default first.",
126+
};
127+
refreshImportPages();
128+
return {
129+
ok: true,
130+
model,
131+
message: "The model was removed from future imports.",
132+
};
51133
} catch {
52-
return { ok: false, model, message: "The model could not be saved." };
134+
return { ok: false, model, message: "The model could not be removed." };
53135
}
136+
}
54137

55-
revalidatePath("/admin");
56-
revalidatePath("/admin/dashboard");
57-
return {
58-
ok: true,
59-
model: requested,
60-
message: `Imports now use ${requested}.`,
61-
};
138+
export async function setImportModelVisibility(
139+
model: string,
140+
visible: boolean,
141+
): Promise<ImportModelActionResult> {
142+
if (!(await canManageCourseImports()))
143+
return {
144+
ok: false,
145+
model,
146+
message: "Import management permission is required.",
147+
};
148+
try {
149+
if (typeof visible !== "boolean")
150+
throw new Error("The visibility is invalid.");
151+
const supabase = await createClient();
152+
const { data, error } = await supabase
153+
.from("import_models")
154+
.update({ visible })
155+
.eq("id", assertOpenRouterModel(model))
156+
.eq("enabled", true)
157+
.select("id")
158+
.single();
159+
if (error || !data)
160+
return {
161+
ok: false,
162+
model,
163+
message:
164+
"The model visibility could not be changed. Choose another default before hiding it.",
165+
};
166+
refreshImportPages();
167+
return {
168+
ok: true,
169+
model,
170+
message: visible
171+
? "The model is available for selection."
172+
: "The model is hidden from selection.",
173+
};
174+
} catch {
175+
return {
176+
ok: false,
177+
model,
178+
message: "The model visibility could not be changed.",
179+
};
180+
}
62181
}

0 commit comments

Comments
 (0)