aegida-console / lib / chat / catalog.ts
catalog.ts
Raw
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));
}