338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import { join } from 'node:path';
|
|
import { createRouter } from '../../create-router';
|
|
import { DATA_PATH } from '../../data-path';
|
|
|
|
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');
|
|
|
|
// --- 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));
|
|
}
|
|
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
});
|