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 { 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(); 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 { 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } function hasOnlyKeys(value: Record, allowed: string[]): boolean { const keys = Object.keys(value); return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); }