import "server-only";
import { AUTO_MODEL, type ChatModel } from "@/lib/chat/models";
import { isModelId } from "@/lib/chat/types";
const maximumCatalogBytes = 1_048_576;
const ownerPattern = /^[a-z0-9][a-z0-9._-]{0,127}$/;
export type CatalogErrorReason = "unauthorized" | "unavailable";
export class CatalogError extends Error {
constructor(readonly reason: CatalogErrorReason = "unavailable") {
super("Model catalog is unavailable");
this.name = "CatalogError";
}
}
export async function listGateModels(
authorization: string,
signal: AbortSignal,
): Promise<ChatModel[]> {
if (!/^Bearer [^\s,]+$/.test(authorization) || !(signal instanceof AbortSignal)) {
throw new CatalogError();
}
let response: Response;
try {
response = await fetch(`${gateOrigin()}/v1/models`, {
method: "GET",
headers: { Accept: "application/json", Authorization: authorization },
cache: "no-store",
redirect: "error",
signal,
});
} catch (error) {
if (signal.aborted || isAbortError(error)) throw error;
throw new CatalogError();
}
if (response.status === 401) throw new CatalogError("unauthorized");
if (!response.ok) throw new CatalogError();
try {
return parseOpenAIModelList(
JSON.parse(await readBoundedText(response, maximumCatalogBytes)) as unknown,
);
} catch (error) {
if (error instanceof CatalogError) throw error;
if (signal.aborted || isAbortError(error)) throw error;
throw new CatalogError();
}
}
export function parseOpenAIModelList(value: unknown): ChatModel[] {
if (!isRecord(value) || value.object !== "list" || !Array.isArray(value.data)) {
throw new CatalogError();
}
const models: ChatModel[] = [];
const seen = new Set<string>();
let auto = AUTO_MODEL;
for (const item of value.data) {
const model = parseOpenAIModel(item);
if (!model || seen.has(model.id)) continue;
seen.add(model.id);
if (model.id === "auto") {
auto = AUTO_MODEL;
continue;
}
models.push(model);
}
return [auto, ...models];
}
export function toChatModel(id: string, owner: string): ChatModel {
if (id === "auto") return AUTO_MODEL;
return {
id,
name: id
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" "),
description: ownerPattern.test(owner)
? `Владелец модели: ${owner}.`
: "Модель доступна через Aegida Gate.",
};
}
function parseOpenAIModel(value: unknown): ChatModel | null {
if (
!isRecord(value) ||
!hasOnlyKeys(value, ["id", "object", "created", "owned_by"]) ||
!isModelId(value.id) ||
value.object !== "model" ||
!Number.isSafeInteger(value.created) ||
(value.created as number) <= 0 ||
typeof value.owned_by !== "string"
) {
return null;
}
return toChatModel(value.id, value.owned_by);
}
function gateOrigin(): string {
const raw = process.env.AEGIDA_GATE_URL;
if (!raw) throw new CatalogError();
try {
const url = new URL(raw);
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
!url.hostname ||
url.username ||
url.password ||
(url.pathname !== "" && url.pathname !== "/") ||
url.search ||
url.hash
) {
throw new CatalogError();
}
return url.origin;
} catch {
throw new CatalogError();
}
}
async function readBoundedText(response: Response, maximum: number): Promise<string> {
if (!response.body) throw new CatalogError();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maximum) {
await reader.cancel();
throw new CatalogError();
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
function isAbortError(value: unknown): boolean {
return value instanceof DOMException && value.name === "AbortError";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: string[]): boolean {
const keys = Object.keys(value);
return keys.length === allowed.length && keys.every((key) => allowed.includes(key));
}