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 { 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 { const base = url.replace(/\/+$/, ''); const timeout = 5000; const headers: Record = {}; 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> { try { const file = Bun.file(API_KEYS_FILE); if (!(await file.exists())) return {}; return (await file.json()) as Record; } catch { return {}; } } async function writeApiKeys(keys: Record) { 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 = {}; await Promise.all(providers.map(async (p) => { const base = p.url.replace(/\/+$/, ''); const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models'; const headers: Record = {}; 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); });