Files
learnharness/app/api/custom-provider/route.ts
T
iamabhishekt b4a13f5c3b feat: manual model IDs when /models is unavailable
Hosted OpenAI-compatible APIs often serve chat but not GET /models
(Volcengine Ark Agent Plan returns 404), so connect failed. Accept a
typed list of model IDs: merged into discovered models when /models
works, used alone when it does not. The typed list is stored as
customProvider.manualModels so reconnects and pin saves keep it.
2026-10-28 16:16:30 -04:00

114 lines
4.7 KiB
TypeScript

/**
* The custom endpoint's connect surface (one provider in the Keys list).
* GET → non-secret config { baseUrl, models, overrides } + keyPresent (masked).
* POST → connect: validate + normalize the base URL, probe /models (fail
* fast), store the key, persist { baseUrl, models }, re-sync the
* provider into piModels(). Discovery lives here because connecting
* and discovering are one user action. Stored user pins survive a
* reconnect, pruned to the ids the endpoint still advertises.
* PATCH → store per-model pins only. No probe: pins must be editable with
* the local server switched off (the case they exist for).
* DELETE → disconnect: drop the stored config + the stored key and unregister
* the provider (the mirror image of POST; idempotent).
*/
import {
deleteKey,
discoverModels,
effectiveSettings,
keyValue,
mergeManualModels,
parseCustomModelOverridesBody,
readSettingsRecord,
saveKey,
syncCustomProvider,
writeSettingsRecord,
} from "@/src/server";
import { CUSTOM_PROVIDER, normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides } from "@/src/core";
export const dynamic = "force-dynamic";
export async function GET() {
const cp = effectiveSettings().customProvider;
return Response.json({
baseUrl: cp.baseUrl,
models: cp.models,
overrides: cp.overrides,
manualModels: cp.manualModels,
keyPresent: keyValue(CUSTOM_PROVIDER.envVar) !== undefined,
});
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as { baseUrl?: unknown; key?: unknown; manualModels?: unknown } | null;
const base = normalizeBaseUrl(typeof body?.baseUrl === "string" ? body.baseUrl : "");
if (!base) return Response.json({ error: "Base URL must be a valid http(s) URL" }, { status: 400 });
// Empty key string clears it, absent leaves it stored; only a real key rides the probe.
const keyProvided = typeof body?.key === "string";
const key = keyProvided ? (body!.key as string).trim() : undefined;
const manualModels = parseModelIds(body?.manualModels);
const found = await discoverModels(base, key ? key : undefined);
if (!found.ok && !manualModels.length) {
return Response.json({ error: `${found.error}. If this endpoint has no /models list, add model IDs manually.` }, { status: 502 });
}
const models = mergeManualModels(found.ok ? found.models : [], manualModels);
if (keyProvided) {
if (key) {
const saved = saveKey(CUSTOM_PROVIDER.envVar, key);
if (!saved.ok) return Response.json({ error: saved.error }, { status: 400 });
} else {
deleteKey(CUSTOM_PROVIDER.envVar);
}
}
const record = readSettingsRecord() ?? { version: 1 };
// User pins are never rewritten by discovery — only pruned to what the
// endpoint still advertises (the single prune law, core/settings.ts).
const overrides = sanitizeCustomModelOverrides(
record.customProvider?.overrides,
models.map((m) => m.id),
);
const next = writeSettingsRecord({
...record,
customProvider: { baseUrl: base, models, overrides, ...(manualModels.length ? { manualModels } : {}) },
});
syncCustomProvider(next.customProvider);
return Response.json({ ok: true, models: next.customProvider?.models ?? [] });
}
/** Store the per-model pins (replace-whole). Pruned against the STORED model
* list, never a probe — an offline endpoint keeps its pins editable. */
export async function PATCH(request: Request) {
const parsed = parseCustomModelOverridesBody(await request.json().catch(() => null));
if (!parsed.ok) return Response.json({ error: parsed.error }, { status: 400 });
const cp = effectiveSettings().customProvider;
if (!cp.baseUrl) return Response.json({ error: "No custom endpoint configured" }, { status: 409 });
const overrides = sanitizeCustomModelOverrides(
parsed.settings,
cp.models.map((m) => m.id),
);
const record = readSettingsRecord() ?? { version: 1 };
const next = writeSettingsRecord({
...record,
customProvider: { baseUrl: cp.baseUrl, models: cp.models, overrides, ...(cp.manualModels.length ? { manualModels: cp.manualModels } : {}) },
});
syncCustomProvider(next.customProvider);
return Response.json({ ok: true, overrides: next.customProvider?.overrides ?? {} });
}
/** Disconnect: forget the endpoint AND its key, then unregister the provider.
* Deleting the section (rather than blanking baseUrl) keeps settings.json the
* same shape as a never-connected install. Idempotent by construction. */
export async function DELETE() {
const record = readSettingsRecord() ?? { version: 1 };
const { customProvider: _dropped, ...rest } = record;
const next = writeSettingsRecord(rest);
deleteKey(CUSTOM_PROVIDER.envVar);
syncCustomProvider(next.customProvider);
return Response.json({ ok: true });
}