Files
platform/src/servers/api/server-settings/pi-mono.ts
T

389 lines
13 KiB
TypeScript

import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
import { syncAllUserPiConfigs } from './sync-user-pi-config';
import { invalidateModelCache } from '../pi/list-models';
export const piMonoRouter = createRouter();
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
const ACCESS_POLICY_FILE = join(DATA_PATH, 'pi_access_policy.json');
// --- Local provider types ---
export type LocalProvider = {
id: string;
name: string;
url: string;
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
};
type ProbeResult = {
success: boolean;
apiType?: LocalProvider['apiType'];
name?: string;
needsAuth?: boolean;
authType?: 'api-key' | 'basic' | 'unknown';
models?: string[];
error?: string;
};
export async function readLocalProviders(): Promise<LocalProvider[]> {
try {
const file = Bun.file(LOCAL_PROVIDERS_FILE);
if (!(await file.exists())) return [];
return (await file.json()) as LocalProvider[];
} catch {
return [];
}
}
async function writeLocalProviders(providers: LocalProvider[]) {
await Bun.write(LOCAL_PROVIDERS_FILE, JSON.stringify(providers, null, 2));
}
async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<ProbeResult> {
const base = url.replace(/\/+$/, '');
const timeout = 5000;
const headers: Record<string, string> = {};
if (auth?.type === 'api-key') {
headers['Authorization'] = `Bearer ${auth.apiKey}`;
} else if (auth?.type === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`;
}
const tryFetch = async (path: string) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
return res;
} catch {
return null;
} finally {
clearTimeout(timer);
}
};
// 1. Try Ollama: GET /api/tags
const ollamaRes = await tryFetch('/api/tags');
if (ollamaRes) {
if (ollamaRes.status === 401 || ollamaRes.status === 403) {
return { success: true, apiType: 'ollama', name: 'Ollama', needsAuth: true, authType: 'unknown' };
}
if (ollamaRes.ok) {
try {
const data = (await ollamaRes.json()) as { models?: { name: string }[] };
if (data.models) {
return {
success: true,
apiType: 'ollama',
name: 'Ollama',
needsAuth: false,
models: data.models.map((m) => m.name),
};
}
} catch {
// not ollama, continue
}
}
}
// 2. Try LM Studio: GET /v1/models (LM Studio returns specific format)
// 3. Try OpenAI-compatible: GET /v1/models
const oaiRes = await tryFetch('/v1/models');
if (oaiRes) {
if (oaiRes.status === 401 || oaiRes.status === 403) {
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
}
if (oaiRes.ok) {
try {
const data = (await oaiRes.json()) as { data?: { id: string }[]; object?: string };
if (data.data) {
// LM Studio includes "lm-studio" in model IDs
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
return {
success: true,
apiType,
name,
needsAuth: false,
models: data.data.map((m) => m.id),
};
}
} catch {
// not valid JSON
}
}
}
// 4. Try bare /models (some servers)
const bareRes = await tryFetch('/models');
if (bareRes) {
if (bareRes.status === 401 || bareRes.status === 403) {
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
}
if (bareRes.ok) {
try {
const data = (await bareRes.json()) as { data?: { id: string }[] };
if (data.data) {
return {
success: true,
apiType: 'openai-compatible',
name: 'OpenAI-compatible',
needsAuth: false,
models: data.data.map((m) => m.id),
};
}
} catch {
// continue
}
}
}
return { success: false, error: 'Could not detect API type at this URL' };
}
export async function readApiKeys(): Promise<Record<string, string>> {
try {
const file = Bun.file(API_KEYS_FILE);
if (!(await file.exists())) return {};
return (await file.json()) as Record<string, string>;
} catch {
return {};
}
}
async function writeApiKeys(keys: Record<string, string>) {
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
}
export type AccessPolicy = {
allowedModels: string[]; // e.g. ["anthropic:anthropic/claude-sonnet-4"]
};
export async function readAccessPolicy(): Promise<AccessPolicy> {
try {
const file = Bun.file(ACCESS_POLICY_FILE);
if (!(await file.exists())) return { allowedModels: [] };
return (await file.json()) as AccessPolicy;
} catch {
return { allowedModels: [] };
}
}
async function writeAccessPolicy(policy: AccessPolicy) {
await Bun.write(ACCESS_POLICY_FILE, JSON.stringify(policy, null, 2));
}
export const PROVIDERS: { key: string; env: string[] }[] = [
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
{ key: 'Groq', env: ['GROQ_API_KEY'] },
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
{ key: 'xAI', env: ['XAI_API_KEY'] },
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
];
piMonoRouter.get('/auth', async (ctx) => {
const storedKeys = await readApiKeys();
const providers = PROVIDERS.filter((p) =>
p.env.some((e) => storedKeys[e]?.trim() || process.env[e]?.trim()),
).map((p) => p.key);
return ctx.json({ authenticated: providers.length > 0, providers });
});
const maskValue = (value: string) => {
if (value.length <= 8) return '***';
return value.slice(0, 3) + '...' + value.slice(-3);
};
piMonoRouter.get('/api-keys', async (ctx) => {
const storedKeys = await readApiKeys();
const keys = PROVIDERS.flatMap((p) =>
p.env
.filter((e) => storedKeys[e]?.trim())
.map((e) => ({ env: e, value: maskValue(storedKeys[e]!) })),
);
return ctx.json({ keys });
});
piMonoRouter.put('/api-keys', async (ctx) => {
const { key, value } = await ctx.req.json<{ key: string; value: string }>();
const allEnvs = PROVIDERS.flatMap((p) => p.env);
if (!allEnvs.includes(key)) return ctx.json({ error: 'Invalid key' }, 400);
const keys = await readApiKeys();
if (value.trim()) {
keys[key] = value.trim();
} else {
delete keys[key];
}
await writeApiKeys(keys);
invalidateModelCache();
syncAllUserPiConfigs().catch(() => {});
return ctx.json({ ok: true });
});
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
try {
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
}
};
piMonoRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
});
piMonoRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
stdout: 'pipe',
stderr: 'pipe',
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
// --- Local providers ---
piMonoRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})));
});
piMonoRouter.post('/local-providers/probe', async (ctx) => {
const { url, auth } = await ctx.req.json<{ url: string; auth?: LocalProvider['auth'] }>();
if (!url?.trim()) return ctx.json({ success: false, error: 'URL is required' }, 400);
const result = await probeUrl(url.trim(), auth);
return ctx.json(result);
});
piMonoRouter.post('/local-providers', async (ctx) => {
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
const providers = await readLocalProviders();
const provider: LocalProvider = {
id: crypto.randomUUID(),
name: body.name ?? body.apiType,
url: body.url.replace(/\/+$/, ''),
apiType: body.apiType,
auth: body.auth,
};
providers.push(provider);
await writeLocalProviders(providers);
// Sync to Pi config so Pi knows about this provider
await syncLocalProvidersToPiConfig();
syncAllUserPiConfigs().catch(() => {});
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
});
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
const { id } = ctx.req.param();
const providers = await readLocalProviders();
const filtered = providers.filter((p) => p.id !== id);
if (filtered.length === providers.length) return ctx.json({ error: 'Not found' }, 404);
await writeLocalProviders(filtered);
// Sync to Pi config to remove this provider
await syncLocalProvidersToPiConfig();
syncAllUserPiConfigs().catch(() => {});
return ctx.json({ ok: true });
});
// --- Access policy ---
piMonoRouter.get('/access-policy', async (ctx) => {
const policy = await readAccessPolicy();
return ctx.json(policy);
});
piMonoRouter.put('/access-policy', async (ctx) => {
const body = await ctx.req.json<AccessPolicy>();
const policy: AccessPolicy = {
allowedModels: Array.isArray(body.allowedModels) ? body.allowedModels : [],
};
await writeAccessPolicy(policy);
await syncAllUserPiConfigs();
return ctx.json({ ok: true });
});
piMonoRouter.get('/local-providers/health', async (ctx) => {
const providers = await readLocalProviders();
const results: Record<string, boolean> = {};
await Promise.all(providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}));
return ctx.json(results);
});