413 lines · 13.6 KB · TypeScript
RawDownload
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";import { existsSync } from "node:fs";import { homedir } from "node:os";import path from "node:path";import { getApiSettings, type ApiSettings } from "./settings-service";import { resolveDataDir } from "./data-dir";import type { OpenAICompletionsCompat } from "@earendil-works/pi-ai";import {  normalizeOpenAIModels,  inferReasoningSupport,  type AgentModel,} from "../../../shared/agent/models";import { resolveModelVision } from "../../../controller/contracts/model-capabilities"; const PROVIDER_ID = "local-studio";const USER_PI_PREFIX = "user-pi-"; function userPiAgentDir(): string {  // Prefer $HOME over os.homedir(): Node keeps them in sync but Bun's  // homedir() ignores the env var, which breaks HOME-based test isolation.  return path.join(process.env["HOME"] ?? homedir(), ".pi", "agent");} function userPiModelsPath(): string {  return path.join(userPiAgentDir(), "models.json");} type PiProviderModel = {  id: string;  name?: string;  reasoning?: boolean;  input?: string[];  contextWindow?: number;  maxTokens?: number;  cost?: Record<string, number>;  compat?: Record<string, unknown>;}; type PiProviderConfig = {  baseUrl: string;  apiKey?: string;  api?: string;  authHeader?: boolean;  models?: PiProviderModel[];  compat?: Record<string, unknown>;}; type UserPiProviders = Record<string, PiProviderConfig>; async function loadUserPiProviders(): Promise<UserPiProviders> {  const modelsPath = userPiModelsPath();  if (!existsSync(modelsPath)) return {};  try {    const parsed = JSON.parse(await readFile(modelsPath, "utf-8")) as unknown;    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};    const providers = (parsed as { providers?: unknown }).providers;    if (!providers || typeof providers !== "object" || Array.isArray(providers)) return {};    return providers as UserPiProviders;  } catch {    return {};  }} function userPiModelToAgentModel(  providerName: string,  qualifiedProviderId: string,  model: PiProviderModel,): AgentModel {  const rawId = model.id;  const name = model.name ?? rawId;  const inputs = model.input ?? ["text"];  return {    id: `${qualifiedProviderId}/${rawId}`,    rawId,    name: `${name} · ${providerName}`,    provider: "local-studio",    providerId: qualifiedProviderId,    controllerName: providerName,    contextWindow: model.contextWindow ?? 128_000,    maxTokens: model.maxTokens ?? 65_536,    reasoning: model.reasoning ?? inferReasoningSupport(rawId),    vision: resolveModelVision({ identifiers: [rawId], modalities: [inputs] }),    active: false,  };} export type PiControllerModelsRequest = {  url: string;  apiKey?: string;  name?: string;}; type PiControllerConfig = {  url: string;  apiKey: string;  name?: string;}; type ControllerModels = {  controller: PiControllerConfig;  models: AgentModel[];  providerId: string;}; function controllersPath(agentDir: string): string {  return path.join(agentDir, "controllers.json");} function controllerLabel(controller: PiControllerConfig, index: number): string {  if (controller.name?.trim()) return controller.name.trim();  try {    return new URL(controller.url).host;  } catch {    return index === 0 ? "primary" : `controller ${index + 1}`;  }} function providerIdForController(controller: PiControllerConfig, index: number): string {  if (index === 0) return PROVIDER_ID;  const normalized = controller.url    .replace(/^https?:\/\//i, "")    .replace(/[^a-z0-9]+/gi, "-")    .replace(/^-+|-+$/g, "")    .toLowerCase();  return `${PROVIDER_ID}-${normalized || index + 1}`;} function qualifyModelId(providerId: string, rawId: string): string {  return providerId === PROVIDER_ID ? rawId : `${providerId}/${rawId}`;} function normalizeBackendUrl(value: string): string {  return value.trim().replace(/\/+$/, "");} function normalizeControllerInput(input: PiControllerModelsRequest): PiControllerConfig | null {  const url = normalizeBackendUrl(input.url || "");  if (!url) return null;  const apiKey = input.apiKey?.trim() ?? "";  const name = input.name?.trim();  return {    url,    apiKey,    ...(name ? { name } : {}),  };} function mergeControllers(  settings: ApiSettings,  requested: PiControllerModelsRequest[] = [],): PiControllerConfig[] {  const byUrl = new Map<string, PiControllerConfig>();  const primary = normalizeControllerInput({    url: settings.backendUrl,    apiKey: settings.apiKey,    name: "primary",  });  if (primary) byUrl.set(primary.url, primary);  for (const entry of requested) {    const controller = normalizeControllerInput(entry);    if (!controller) continue;    const existing = byUrl.get(controller.url);    byUrl.set(controller.url, {      ...existing,      ...controller,      apiKey: controller.apiKey || existing?.apiKey || "",    });  }  return [...byUrl.values()];} async function loadPersistedControllers(agentDir: string): Promise<PiControllerModelsRequest[]> {  const file = controllersPath(agentDir);  if (!existsSync(file)) return [];  try {    const parsed = JSON.parse(await readFile(file, "utf-8")) as unknown;    if (!Array.isArray(parsed)) return [];    return parsed      .filter((entry): entry is PiControllerModelsRequest =>        Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),      )      .flatMap((entry) => {        const record = entry as Record<string, unknown>;        return typeof record.url === "string"          ? [              {                url: record.url,                ...(typeof record.apiKey === "string" ? { apiKey: record.apiKey } : {}),                ...(typeof record.name === "string" ? { name: record.name } : {}),              },            ]          : [];      });  } catch {    return [];  }} async function savePersistedControllers(  agentDir: string,  controllers: PiControllerConfig[],): Promise<void> {  await writeFile(controllersPath(agentDir), JSON.stringify(controllers, null, 2), "utf-8");  await chmod(controllersPath(agentDir), 0o600).catch(() => undefined);} async function fetchModelsFromController(  controller: PiControllerConfig,  index: number,  multipleControllers: boolean,): Promise<ControllerModels> {  const backendUrl = normalizeBackendUrl(controller.url);  const headers: HeadersInit = { Accept: "application/json" };  if (controller.apiKey) headers.Authorization = `Bearer ${controller.apiKey}`;  const response = await fetch(`${backendUrl}/v1/models`, { headers, cache: "no-store" });  if (!response.ok) {    throw new Error(`${backendUrl}/v1/models failed with HTTP ${response.status}`);  }  const payload = (await response.json()) as unknown;  const providerId = providerIdForController(controller, index);  const label = controllerLabel(controller, index);  const models = normalizeOpenAIModels(payload && typeof payload === "object" ? payload : {}).map(    (model) => ({      ...model,      id: qualifyModelId(providerId, model.id),      rawId: model.id,      providerId,      controllerUrl: backendUrl,      controllerName: label,      name: multipleControllers ? `${model.name} · ${label}` : model.name,    }),  );  return { controller: { ...controller, url: backendUrl }, models, providerId };} async function fetchModelsFromControllers(controllers: PiControllerConfig[]): Promise<{  models: AgentModel[];  controllerModels: ControllerModels[];}> {  const settled = await Promise.allSettled(    controllers.map((controller, index) =>      fetchModelsFromController(controller, index, controllers.length > 1),    ),  );  const controllerModels = settled    .filter(      (result): result is PromiseFulfilledResult<ControllerModels> => result.status === "fulfilled",    )    .map((result) => result.value);  if (controllerModels.length === 0) {    const firstError = settled.find(      (result): result is PromiseRejectedResult => result.status === "rejected",    );    throw firstError?.reason instanceof Error      ? firstError.reason      : new Error("No controllers returned models.");  }  const seen = new Set<string>();  const models: AgentModel[] = [];  for (const result of controllerModels) {    for (const model of result.models) {      if (seen.has(model.id)) continue;      seen.add(model.id);      models.push(model);    }  }  return { models: models.sort((a, b) => a.name.localeCompare(b.name)), controllerModels };} async function writePiModelsConfig(  controllerModels: ControllerModels[],  userPiProviders: UserPiProviders,): Promise<string> {  const dataDir = resolveDataDir();  const agentDir = path.join(dataDir, "pi-agent");  await mkdir(agentDir, { recursive: true });  await chmod(agentDir, 0o700).catch(() => undefined);   const vllmProviders = Object.fromEntries(    controllerModels.map(({ controller, models, providerId }) => [      providerId,      {        baseUrl: `${controller.url}/v1`,        api: "openai-completions",        apiKey: controller.apiKey || "local-studio",        authHeader: Boolean(controller.apiKey),        compat: {          supportsDeveloperRole: false,          supportsReasoningEffort: false,        },        models: modelsToPiModels(models),      },    ]),  );   // Merge user-pi providers so the SDK runtime can route to them.  // Each provider ID is prefixed to avoid colliding with Local Studio's own.  const mergedProviders: Record<string, unknown> = { ...vllmProviders };  for (const [name, config] of Object.entries(userPiProviders)) {    const qualifiedId = `${USER_PI_PREFIX}${name}`;    mergedProviders[qualifiedId] = {      baseUrl: config.baseUrl,      ...(config.apiKey ? { apiKey: config.apiKey } : {}),      ...(config.api ? { api: config.api } : {}),      ...(config.authHeader !== undefined ? { authHeader: config.authHeader } : {}),      ...(config.compat ? { compat: config.compat } : {}),      models: config.models ?? [],    };  }   const config = { providers: mergedProviders };   const modelsPath = path.join(agentDir, "models.json");  await writeFile(modelsPath, JSON.stringify(config, null, 2), "utf-8");  await chmod(modelsPath, 0o600).catch(() => undefined);  return agentDir;} export function resolvePiModelSelection(modelId: string): { providerId: string; modelId: string } {  const separator = modelId.indexOf("/");  if (separator > 0) {    const maybeProvider = modelId.slice(0, separator);    if (maybeProvider.startsWith(USER_PI_PREFIX) || maybeProvider.startsWith(`${PROVIDER_ID}-`)) {      return { providerId: maybeProvider, modelId: modelId.slice(separator + 1) };    }  }  return { providerId: PROVIDER_ID, modelId };} export async function refreshPiModels(  requestedControllers?: PiControllerModelsRequest[],): Promise<{ models: AgentModel[]; agentDir: string }> {  const settings = await getApiSettings();  const dataDir = resolveDataDir();  const agentDir = path.join(dataDir, "pi-agent");  await mkdir(agentDir, { recursive: true });  await chmod(agentDir, 0o700).catch(() => undefined);  const persisted =    requestedControllers && requestedControllers.length > 0      ? requestedControllers      : await loadPersistedControllers(agentDir);  const controllers = mergeControllers(settings, persisted);  await savePersistedControllers(agentDir, controllers);  const { models, controllerModels } = await fetchModelsFromControllers(controllers);   // Load providers from ~/.pi/agent/models.json so models configured in the  // user's standalone Pi install (kimi, openrouter, cerebras, etc.) are  // available in the agent model picker alongside Local Studio's own backend.  const userPiProviders = await loadUserPiProviders();  const userPiModels: AgentModel[] = [];  for (const [providerName, config] of Object.entries(userPiProviders)) {    const qualifiedProviderId = `${USER_PI_PREFIX}${providerName}`;    for (const model of config.models ?? []) {      userPiModels.push(userPiModelToAgentModel(providerName, qualifiedProviderId, model));    }  }   const allModels = [...models, ...userPiModels];  const writtenAgentDir = await writePiModelsConfig(controllerModels, userPiProviders);  return { models: allModels, agentDir: writtenAgentDir };}// Moved here from the shared models module: only the runtime needs the// pi-model mapping, and the OpenAICompletionsCompat type must resolve against// the SDK install.function isDeepSeekReasoningModel(model: AgentModel): boolean {  const id = `${model.id} ${model.rawId ?? ""} ${model.name}`.toLowerCase();  return model.reasoning && id.includes("deepseek");} const VLLM_OPENAI_COMPAT: OpenAICompletionsCompat = {  supportsStore: false,  supportsDeveloperRole: false,  supportsReasoningEffort: false,  supportsStrictMode: false,  supportsUsageInStreaming: true,  maxTokensField: "max_tokens",}; export function modelsToPiModels(models: AgentModel[]) {  return models.map((model) => {    const deepSeekReasoning = isDeepSeekReasoningModel(model);    return {      id: model.rawId ?? model.id,      name: model.name,      reasoning: model.reasoning,      input: model.vision ? ["text", "image"] : ["text"],      contextWindow: model.contextWindow,      maxTokens: model.maxTokens,      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },      ...(deepSeekReasoning        ? {            thinkingLevelMap: {              off: null,              minimal: null,              low: "low",              medium: "medium",              high: "high",              xhigh: "max",            },          }        : {}),      compat: {        ...VLLM_OPENAI_COMPAT,        ...(deepSeekReasoning          ? {              thinkingFormat: "deepseek",              requiresReasoningContentOnAssistantMessages: true,            }          : {}),      },    };  });}