import { join } from 'node:path'; import { createRouter } from '../../create-router'; import { PI_CONFIG_DIR } from '../../data-path'; import { invalidateModelCache } from '../pi/list-models'; import { logger } from '../pi/logger'; import { readConfigValue, writeConfigValue } from 'officerdb'; export const piMonoRouter = createRouter(); const PI_AUTH_FILE = join(PI_CONFIG_DIR, 'auth.json'); const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.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; }; type PiModelConfig = { providers: Record; }; type OfficerMeta = { name: string; url: string; apiType: LocalProvider['apiType']; auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }; }; async function readModelsConfig(): Promise { try { const file = Bun.file(PI_MODELS_FILE); if (!(await file.exists())) return { providers: {} }; return (await file.json()) as PiModelConfig; } catch { return { providers: {} }; } } async function writeModelsConfig(config: PiModelConfig): Promise { await Bun.write(PI_MODELS_FILE, JSON.stringify(config, null, 2)); } /** * Read local providers from models.json by extracting officer-local-* entries. */ export async function readLocalProviders(): Promise { const config = await readModelsConfig(); const providers: LocalProvider[] = []; for (const [key, entry] of Object.entries(config.providers)) { if (!key.startsWith('officer-local-') || !entry._officer) continue; const id = key.replace('officer-local-', ''); providers.push({ id, name: entry._officer.name, url: entry._officer.url, apiType: entry._officer.apiType, auth: entry._officer.auth, }); } return providers; } 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' }; } // --- Model fetching for local providers --- const REASONING_PATTERNS = [/\bqwen3\b/i, /\bqwq\b/i, /\bdeepseek-r1\b/i, /\br1\b/i, /\breasoning\b/i, /\bthink/i]; function isReasoningModel(modelId: string): boolean { return REASONING_PATTERNS.some((p) => p.test(modelId)); } async function fetchModelsFromProvider( lp: LocalProvider, ): Promise<{ id: string; name?: string; contextWindow?: number; maxTokens?: number }[]> { try { const base = lp.url.replace(/\/+$/, ''); const path = lp.apiType === 'ollama' ? '/api/tags' : '/v1/models'; const headers: Record = {}; if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`; else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); const res = await fetch(`${base}${path}`, { headers, signal: controller.signal }); clearTimeout(timer); if (!res.ok) return []; const data = await res.json(); if (lp.apiType === 'ollama' && data.models) { return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 })); } else if (data.data) { return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 })); } return []; } catch { return []; } } /** * Write a local provider entry directly into models.json with probed models. */ async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise { const models = await fetchModelsFromProvider(lp); if (models.length === 0) { logger.warn('No models found for local provider', { provider: lp.name }); } const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; const config = await readModelsConfig(); config.providers[`officer-local-${lp.id}`] = { baseUrl, apiKey: lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none', api: 'openai-completions', models: models.map((m) => ({ id: m.id, name: m.name || m.id, reasoning: isReasoningModel(m.id), input: ['text'], contextWindow: m.contextWindow || 128000, maxTokens: m.maxTokens || 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, })), _officer: { name: lp.name, url: lp.url, apiType: lp.apiType, auth: lp.auth, }, }; await writeModelsConfig(config); invalidateModelCache(); } async function removeLocalProviderFromModelsConfig(id: string): Promise { const config = await readModelsConfig(); const key = `officer-local-${id}`; if (!(key in config.providers)) return false; delete config.providers[key]; await writeModelsConfig(config); invalidateModelCache(); return true; } /** * Re-probe all local providers and update their models in models.json. */ async function refreshLocalProviders(): Promise { const config = await readModelsConfig(); for (const [key, entry] of Object.entries(config.providers)) { if (!key.startsWith('officer-local-') || !entry._officer) continue; const lp: LocalProvider = { id: key.replace('officer-local-', ''), name: entry._officer.name, url: entry._officer.url, apiType: entry._officer.apiType, auth: entry._officer.auth, }; const models = await fetchModelsFromProvider(lp); const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; entry.baseUrl = baseUrl; entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none'; entry.models = models.map((m) => ({ id: m.id, name: m.name || m.id, reasoning: isReasoningModel(m.id), input: ['text'], contextWindow: m.contextWindow || 128000, maxTokens: m.maxTokens || 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, })); } await writeModelsConfig(config); invalidateModelCache(); } type AuthEntry = { type: 'api_key'; key: string }; type AuthJson = Record; async function readAuthJson(): Promise { try { const file = Bun.file(PI_AUTH_FILE); if (!(await file.exists())) return {}; return (await file.json()) as AuthJson; } catch { return {}; } } async function writeAuthJson(auth: AuthJson): Promise { await Bun.write(PI_AUTH_FILE, JSON.stringify(auth, null, 2)); } /** * Hardcoded map of display name → Pi provider ID. * Pi's native providers have built-in models; adding a key to auth.json * is all that's needed to make their models appear in `pi --list-models`. */ export const PROVIDERS: { key: string; piId: string }[] = [ { key: 'Anthropic', piId: 'anthropic' }, { key: 'OpenAI', piId: 'openai' }, { key: 'Google', piId: 'google' }, { key: 'Groq', piId: 'groq' }, { key: 'Mistral', piId: 'mistral' }, { key: 'xAI', piId: 'xai' }, { key: 'OpenRouter', piId: 'openrouter' }, { key: 'MiniMax', piId: 'minimax' }, { key: 'Hugging Face', piId: 'huggingface' }, { key: 'Azure OpenAI', piId: 'azure-openai-responses' }, { key: 'OpenCode Zen', piId: 'zai' }, { key: 'Cerebras', piId: 'cerebras' }, ]; piMonoRouter.get('/auth', async (ctx) => { const auth = await readAuthJson(); const providers = PROVIDERS.filter((p) => auth[p.piId]?.key?.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 auth = await readAuthJson(); const keys = PROVIDERS .filter((p) => auth[p.piId]?.key?.trim()) .map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) })); return ctx.json({ keys }); }); piMonoRouter.put('/api-keys', async (ctx) => { const { provider, value } = await ctx.req.json<{ provider: string; value: string }>(); if (!PROVIDERS.some((p) => p.piId === provider)) return ctx.json({ error: 'Invalid provider' }, 400); const auth = await readAuthJson(); if (value.trim()) { auth[provider] = { type: 'api_key', key: value.trim() }; } else { delete auth[provider]; } await writeAuthJson(auth); invalidateModelCache(); return ctx.json({ ok: true }); }); type AccessPolicy = { allowedModels: string[] }; const ACCESS_POLICY_KEY = 'pi-access-policy'; piMonoRouter.get('/access-policy', async (ctx) => { const policy = await readConfigValue(ACCESS_POLICY_KEY, { allowedModels: [] }); return ctx.json(policy); }); piMonoRouter.put('/access-policy', async (ctx) => { const body = await ctx.req.json(); await writeConfigValue(ACCESS_POLICY_KEY, body); return ctx.json(body); }); const REMOTE_HEALTH_CONFIG: Record string); headers: (key: string) => Record; }> = { openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) }, google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) }, groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, }; piMonoRouter.get('/api-keys/health', async (ctx) => { const auth = await readAuthJson(); const results: Record = {}; const checks = PROVIDERS .filter((p) => auth[p.piId]?.key?.trim()) .map(async (p) => { const config = REMOTE_HEALTH_CONFIG[p.piId]; if (!config) { results[p.piId] = null; return; } const key = auth[p.piId]!.key; const url = typeof config.url === 'function' ? config.url(key) : config.url; const headers = config.headers(key); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); try { const res = await fetch(url, { headers, signal: controller.signal }); results[p.piId] = res.ok; } catch { results[p.piId] = false; } finally { clearTimeout(timer); } }); await Promise.all(checks); return ctx.json(results); }); 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 provider: LocalProvider = { id: crypto.randomUUID(), name: body.name ?? body.apiType, url: body.url.replace(/\/+$/, ''), apiType: body.apiType, auth: body.auth, }; await addLocalProviderToModelsConfig(provider); 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 removed = await removeLocalProviderFromModelsConfig(id); if (!removed) return ctx.json({ error: 'Not found' }, 404); return ctx.json({ ok: true }); }); piMonoRouter.post('/local-providers/refresh', async (ctx) => { await refreshLocalProviders(); 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); });