AkurAI Build
Menu

popagent

public

Latest change 5f3336a97ad971230462d4a65cf417942a3647ab - Deduplicate model catalog IDs by AkurAI Build

import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { appLogger } from "./observability";
import type { ModelCatalog, ModelProfile } from "./api-types";

const BASE_URL = process.env.NINEROUTER_URL ?? "http://127.0.0.1:20128/v1";
const API_KEY = process.env.NINEROUTER_API_KEY ?? "";
const EMBEDDING_BASE_URL = process.env.POPAGENT_EMBEDDING_URL ?? BASE_URL;
const EMBEDDING_API_KEY = process.env.POPAGENT_EMBEDDING_API_KEY ?? API_KEY;

/** The local Ornith llama.cpp template accepts exactly one leading system message. */
export function normalizeChatRequest(request: Record<string, unknown>): Record<string, unknown> {
  if (typeof request.model !== "string" || !/\/ornith-1\.0-9b-mtp-q4_k_m$/i.test(request.model) || !Array.isArray(request.messages)) return request;
  const messages = request.messages as Array<{ role?: unknown; content?: unknown }>;
  let systemCount = 0;
  while (messages[systemCount]?.role === "system") systemCount++;
  if (systemCount < 2 || messages.slice(0, systemCount).some((message) => typeof message.content !== "string")) return request;
  return {
    ...request,
    messages: [
      { ...messages[0], content: messages.slice(0, systemCount).map((message) => message.content).join("\n\n") },
      ...messages.slice(systemCount),
    ],
  };
}

const ninerouter = createOpenAICompatible({
  name: "9router",
  baseURL: BASE_URL,
  apiKey: API_KEY,
  transformRequestBody: normalizeChatRequest,
});
const embeddings = createOpenAICompatible({
  name: "embeddings",
  baseURL: EMBEDDING_BASE_URL,
  apiKey: EMBEDDING_API_KEY,
});

/** Language model for one explicit 9Router model id. */
export function resolveModel(modelId: string) {
  return ninerouter.chatModel(modelId);
}

/** Embedding model for the configured OpenAI-compatible embedding service. */
export function resolveEmbeddingModel(
  modelId = process.env.POPAGENT_EMBEDDING_MODEL ?? "text-embedding-3-small",
) {
  return embeddings.embeddingModel(modelId);
}

const MODEL_PROFILES: Record<string, ModelProfile> = {
  "best-orchestrator": {
    label: "Fusion orchestrator",
    role: "orchestrator",
    summary: "Sol Max and Fable reason in parallel; OmniRoute synthesizes one final direction.",
    members: ["GPT-5.6 Sol Max", "Claude Fable 5"],
  },
  "fast-orchestrator": {
    label: "Fast orchestrator",
    role: "fast",
    summary: "Alternates Sol Max and Fable for routine work with one upstream call.",
    members: ["GPT-5.6 Sol Max", "Claude Fable 5"],
  },
  "titan/ornith-1.0-9b-mtp-q4_k_m": {
    label: "Local build model",
    role: "local",
    summary: "Titan-resident Ornith executes repository work without consuming cloud-model quota.",
    members: ["Ornith 1.0 9B MTP"],
  },
};

function availableModelProfiles(models: string[]): Record<string, ModelProfile> {
  return Object.fromEntries(
    models.flatMap((model) => MODEL_PROFILES[model] ? [[model, MODEL_PROFILES[model]]] : []),
  );
}


/** Selectable model ids and their context limits from the 9router gateway. */
export async function listModelCatalog(): Promise<ModelCatalog> {
  const res = await fetch(`${BASE_URL}/models`, {
    headers: { authorization: `Bearer ${API_KEY}` },
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) throw new Error(`9router /models failed: ${res.status}`);
  const { data } = (await res.json()) as {
    data: {
      id: string;
      context_length?: number;
      max_input_tokens?: number;
      capabilities?: { contextWindow?: number };
    }[];
  };
  const contextWindows: Record<string, number> = {};
  for (const model of data) {
    const limit =
      model.context_length ??
      model.max_input_tokens ??
      model.capabilities?.contextWindow ??
      0;
    contextWindows[model.id] = Math.max(contextWindows[model.id] ?? 0, limit);
  }
  const models = [...new Set(data.map((model) => model.id))];
  return {
    models,
    contextWindows,
    profiles: availableModelProfiles(models),
  };
}

let catalogCache: { catalog: ModelCatalog; expires: number } | undefined;
export async function isKnownModel(model: string): Promise<boolean> {
  if (catalogCache && catalogCache.expires > Date.now()) return catalogCache.catalog.models.includes(model);
  try {
    const catalog = await listModelCatalog();
    catalogCache = { catalog, expires: Date.now() + 60_000 };
    return catalog.models.includes(model);
  } catch (error) {
    appLogger().warn("model.catalog.unavailable", { error });
    return true;
  }
}