This commit is contained in:
2026-02-20 04:32:50 +00:00
parent d7503ca56b
commit d9f6eb14ed
24 changed files with 1426 additions and 1183 deletions
+1
View File
@@ -20,6 +20,7 @@ export type ClientMessage =
resourceChatDir?: string;
taskInfo?: TaskInfo;
}
| { type: 'resume'; sessionId: string }
| { type: 'stop' };
// Server → Client
+21 -31
View File
@@ -1,54 +1,44 @@
import { Hono } from 'hono';
import type { HonoVariables } from '@@/create-router';
import { readApiKeys } from '@@/api/server-settings/pi-mono';
export const piMonoModelsRouter = new Hono<{ Variables: HonoVariables }>();
// Hardcoded fallback models — pi supports many providers but these are the most common
const FALLBACK_MODELS = [
{ id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', provider: 'anthropic', providerId: 'anthropic' },
{ id: 'claude-opus-4-20250918', name: 'Claude Opus 4', provider: 'anthropic', providerId: 'anthropic' },
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', providerId: 'openai' },
{ id: 'o3', name: 'o3', provider: 'openai', providerId: 'openai' },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', providerId: 'google' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', providerId: 'google' },
];
piMonoModelsRouter.get('/pi-mono/models', async (ctx) => {
// Spawn a short-lived pi process to query available models
try {
const proc = Bun.spawn(['pi', '--list-models', '--mode', 'json'], {
const storedKeys = await readApiKeys();
const proc = Bun.spawn(['pi', '--list-models'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
env: { ...process.env, ...storedKeys },
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json(FALLBACK_MODELS);
if (proc.exitCode !== 0) return ctx.json([]);
// Parse the output — pi --list-models outputs model info
// Parse the whitespace-separated table output:
// provider model context max-out thinking images
// anthropic claude-sonnet-4-6 200K 128K yes yes
const lines = output.trim().split('\n').filter(Boolean);
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.id && data.provider) {
models.push({
id: data.id,
name: data.name ?? data.id,
provider: data.provider,
providerId: data.provider,
});
}
} catch {
// skip non-JSON lines
}
// Skip header line (first line)
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.trim().split(/\s+/);
if (cols.length < 2) continue;
const [provider, model] = cols;
models.push({
id: `${provider}/${model}`,
name: model!,
provider: provider!,
providerId: provider!,
});
}
return ctx.json(models.length > 0 ? models : FALLBACK_MODELS);
return ctx.json(models);
} catch {
return ctx.json(FALLBACK_MODELS);
return ctx.json([]);
}
});
+270 -134
View File
@@ -15,26 +15,33 @@ import {
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
import { readApiKeys, readLocalProviders } from '@@/api/server-settings/pi-mono';
type WSData = { userId: number; email: string };
type ConnectionState = {
// Pi process state, keyed by sessionId — survives websocket reconnects
type PiSession = {
piProcess: Subprocess | null;
sessionId: string | null;
pendingTitle: string | null;
ws: ServerWebSocket<WSData> | null;
selectedModel: string | null;
pendingAttachmentIds: string[];
cwd: string | null;
resourceChatDir: string | null;
logId: string | null;
fullText: string;
rpcReady: boolean;
killTimer: ReturnType<typeof setTimeout> | null;
};
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
// Session pool — pi processes persist across websocket reconnects
const sessions = new Map<string, PiSession>();
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
// Map ws → sessionId for quick lookup on close
const wsToSession = new Map<ServerWebSocket<WSData>, string>();
// Grace period before killing orphaned pi processes (ms)
const ORPHAN_GRACE_MS = 30_000;
function send(ws: ServerWebSocket<WSData> | null, msg: ServerMessage) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg));
}
function resolveRootDir(email: string, root?: string): string {
@@ -68,29 +75,186 @@ async function buildSkillsPrompt(email: string): Promise<string> {
}
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>) {
const writer = proc.stdin as WritableStream;
const textEncoder = new TextEncoder();
const w = writer.getWriter();
w.write(textEncoder.encode(JSON.stringify(command) + '\n'));
w.releaseLock();
const stdin = proc.stdin;
if (!stdin || typeof stdin === 'number') return;
try {
(stdin as { write: (data: string) => void; flush: () => void }).write(JSON.stringify(command) + '\n');
(stdin as { flush: () => void }).flush();
} catch (err) {
console.error('[pi-mono-ws] writeRpcCommand error:', err);
}
}
function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, workingDir: string) {
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
function getOrCreateSession(sessionId: string): PiSession {
let session = sessions.get(sessionId);
if (!session) {
session = {
piProcess: null,
ws: null,
selectedModel: null,
cwd: null,
resourceChatDir: null,
logId: null,
fullText: '',
killTimer: null,
};
sessions.set(sessionId, session);
}
return session;
}
if (state.selectedModel) {
args.push('--model', state.selectedModel);
function attachWs(sessionId: string, ws: ServerWebSocket<WSData>) {
const session = getOrCreateSession(sessionId);
// Cancel any pending kill timer — the session is alive again
if (session.killTimer) {
clearTimeout(session.killTimer);
session.killTimer = null;
}
session.ws = ws;
wsToSession.set(ws, sessionId);
}
function detachWs(ws: ServerWebSocket<WSData>) {
const sessionId = wsToSession.get(ws);
wsToSession.delete(ws);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session || session.ws !== ws) return;
// Detach ws but keep pi process alive for grace period
session.ws = null;
if (session.piProcess) {
session.killTimer = setTimeout(() => {
// If no new ws has attached, kill the process
if (!session.ws && session.piProcess) {
try {
session.piProcess.kill();
} catch {
// already dead
}
session.piProcess = null;
sessions.delete(sessionId);
}
}, ORPHAN_GRACE_MS);
} else {
sessions.delete(sessionId);
}
}
function resolveLocalModel(modelId: string): { providerId: string; modelName: string } | null {
if (!modelId.startsWith('local:')) return null;
const parts = modelId.split(':');
if (parts.length < 3) return null;
return { providerId: parts[1]!, modelName: parts.slice(2).join(':') };
}
async function handleLocalChat(session: PiSession, prompt: string) {
const parsed = session.selectedModel ? resolveLocalModel(session.selectedModel) : null;
if (!parsed) {
send(session.ws, { type: 'error', message: 'Invalid local model' });
return;
}
const providers = await readLocalProviders();
const provider = providers.find((p) => p.id === parsed.providerId);
if (!provider) {
send(session.ws, { type: 'error', message: 'Local provider not found' });
return;
}
const base = provider.url.replace(/\/+$/, '');
const url = `${base}/v1/chat/completions`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (provider.auth?.type === 'api-key') {
headers['Authorization'] = `Bearer ${provider.auth.apiKey}`;
} else if (provider.auth?.type === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${provider.auth.username}:${provider.auth.password}`)}`;
}
const body = JSON.stringify({
model: parsed.modelName,
messages: [{ role: 'user', content: prompt }],
stream: true,
});
try {
const res = await fetch(url, { method: 'POST', headers, body });
if (!res.ok) {
const text = await res.text();
send(session.ws, { type: 'error', message: `Local provider error: ${res.status} ${text}` });
return;
}
session.fullText = '';
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const chunk = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] };
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
session.fullText += delta;
send(session.ws, { type: 'assistant:partial', text: delta });
}
} catch {
// skip unparseable chunks
}
}
}
if (session.fullText) {
send(session.ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(session.ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
if (session.logId) {
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(session.logId);
session.logId = null;
}
} catch (err) {
send(session.ws, { type: 'error', message: `Local provider error: ${err}` });
}
}
async function spawnPiProcess(session: PiSession, workingDir: string) {
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
if (session.selectedModel) {
args.push('--model', session.selectedModel);
}
const storedKeys = await readApiKeys();
const proc = Bun.spawn(args, {
cwd: workingDir,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env },
env: { ...process.env, ...storedKeys },
});
state.piProcess = proc;
session.piProcess = proc;
// Read stdout line-by-line for JSON events
const reader = proc.stdout.getReader();
@@ -111,7 +275,7 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
handlePiEvent(ws, state, event);
handlePiEvent(session, event);
} catch {
// skip unparseable lines
}
@@ -144,21 +308,19 @@ function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, wor
// Handle process exit
proc.exited.then((code) => {
console.log(`[pi-mono-ws] pi process exited with code ${code}`);
if (state.piProcess === proc) {
state.piProcess = null;
if (session.piProcess === proc) {
session.piProcess = null;
}
});
}
function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, event: Record<string, unknown>) {
function handlePiEvent(session: PiSession, event: Record<string, unknown>) {
const type = event.type as string;
const ws = session.ws;
// RPC responses (type === 'response')
if (type === 'response') {
const command = event.command as string;
if (command === 'get_available_models' && event.success) {
// Models are handled by the REST endpoint, not here
}
if (command === 'prompt' && !event.success) {
send(ws, { type: 'error', message: (event.error as string) ?? 'Prompt failed' });
}
@@ -167,29 +329,27 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
switch (type) {
case 'agent_start':
state.fullText = '';
session.fullText = '';
break;
case 'message_update': {
// message_update contains assistantMessageEvent with content deltas
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
if (!ame) break;
const ameType = ame.type as string;
if (ameType === 'text_delta') {
const delta = ame.delta as string;
state.fullText += delta;
session.fullText += delta;
send(ws, { type: 'assistant:partial', text: delta });
}
break;
}
case 'message_end': {
// Full assistant message complete
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
break;
}
@@ -199,15 +359,14 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
const toolName = (event.toolName as string) ?? 'unknown';
const args = (event.args as Record<string, unknown>) ?? {};
// Commit any streaming text before tool use
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(ws, { type: 'tool:use', toolName, toolInput: args, toolUseId: toolCallId });
if (state.logId) appendToLog(state.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
if (session.logId) appendToLog(session.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
break;
}
@@ -218,8 +377,8 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
send(ws, { type: 'tool:result', toolUseId: toolCallId, output, isError });
if (state.logId)
appendToLog(state.logId, {
if (session.logId)
appendToLog(session.logId, {
role: 'tool',
toolName: '',
toolInput: {},
@@ -231,26 +390,24 @@ function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, even
}
case 'agent_end': {
// Commit any remaining streaming text
if (state.fullText) {
send(ws, { type: 'assistant:text', text: state.fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
state.fullText = '';
if (session.fullText) {
send(ws, { type: 'assistant:text', text: session.fullText });
if (session.logId) appendToLog(session.logId, { role: 'assistant', text: session.fullText });
session.fullText = '';
}
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
if (state.logId) {
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(state.logId);
state.logId = null;
if (session.logId) {
appendToLog(session.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(session.logId);
session.logId = null;
}
break;
}
case 'extension_ui_request': {
// Auto-cancel extension UI requests since we don't support them
if (state.piProcess && event.id) {
writeRpcCommand(state.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
if (session.piProcess && event.id) {
writeRpcCommand(session.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
}
break;
}
@@ -280,37 +437,46 @@ async function handleChat({
resourceChatDir,
taskInfo,
}: HandleChatParams) {
const state = connections.get(ws);
if (!state) return;
const email = ws.data.email;
if (taskInfo && !state.logId) {
state.logId = createTaskLog(ws.data.email, taskInfo, 'pi-mono', model ?? 'unknown');
appendToLog(state.logId, { role: 'user', text: prompt });
// Determine or create session ID
let sid = sessionId ?? wsToSession.get(ws) ?? null;
let isNewSession = false;
if (!sid) {
sid = crypto.randomUUID();
isNewSession = true;
}
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
if (model) state.selectedModel = model;
// Attach this ws to the session (adopts existing pi process if any)
attachWs(sid, ws);
const session = getOrCreateSession(sid);
if (!sessionId && !state.sessionId) {
// New session — generate our own sessionId for officer tracking
const newSessionId = crypto.randomUUID();
state.sessionId = newSessionId;
state.pendingTitle = prompt.slice(0, 100);
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
if (taskInfo && !session.logId) {
session.logId = createTaskLog(email, taskInfo, 'pi-mono', model ?? 'unknown');
appendToLog(session.logId, { role: 'user', text: prompt });
}
send(ws, { type: 'session:init', sessionId: newSessionId, model: model ?? 'pi-mono' });
if (resourceChatDir) session.resourceChatDir = resourceChatDir;
if (model) session.selectedModel = model;
if (state.resourceChatDir) {
const chatDir = join(state.resourceChatDir, 'chat');
const meta = { id: newSessionId, model: model ?? 'pi-mono' };
if (isNewSession) {
const pendingTitle = prompt.slice(0, 100);
// Send session:init AFTER attaching ws so the pi process survives the reconnect
send(ws, { type: 'session:init', sessionId: sid, model: model ?? 'pi-mono' });
if (session.resourceChatDir) {
const chatDir = join(session.resourceChatDir, 'chat');
const meta = { id: sid, model: model ?? 'pi-mono' };
mkdir(chatDir, { recursive: true })
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
.catch(() => {});
} else {
const dir = getPiMonoSessionDir(ws.data.email, newSessionId);
const dir = getPiMonoSessionDir(email, sid);
const meta = {
id: newSessionId,
title: state.pendingTitle ?? 'New chat',
id: sid,
title: pendingTitle,
createdAt: Date.now(),
model: model ?? 'pi-mono',
};
@@ -319,95 +485,67 @@ async function handleChat({
.catch(() => {});
// Move tmp attachments to session dir
if (state.pendingAttachmentIds.length > 0) {
const tmpDir = getTmpAttachmentsDir(ws.data.email);
const destDir = getAttachmentsDir(ws.data.email, 'pi-mono', newSessionId);
if (attachmentIds?.length) {
const tmpDir = getTmpAttachmentsDir(email);
const destDir = getAttachmentsDir(email, 'pi-mono', sid);
mkdir(destDir, { recursive: true })
.then(() =>
Promise.all(
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
),
Promise.all(attachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {}))),
)
.catch(() => {});
state.pendingAttachmentIds = [];
}
}
state.pendingTitle = null;
} else if (sessionId && !state.sessionId) {
state.sessionId = sessionId;
}
// Local provider models — bypass pi, call API directly
if (session.selectedModel?.startsWith('local:')) {
handleLocalChat(session, prompt);
return;
}
// Ensure pi process is running
const homeDir = getHomeDir(ws.data.email);
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
const workingDir = state.cwd ?? homeDir;
const homeDir = getHomeDir(email);
if (cwd) session.cwd = join(resolveRootDir(email, cwd.root), cwd.path);
const workingDir = session.cwd ?? homeDir;
if (!state.piProcess) {
spawnPiProcess(ws, state, workingDir);
if (!session.piProcess) {
await spawnPiProcess(session, workingDir);
// Give pi a moment to initialize
await new Promise((r) => setTimeout(r, 500));
}
if (!state.piProcess) {
send(ws, { type: 'error', message: 'Failed to start pi process' });
if (!session.piProcess) {
send(session.ws, { type: 'error', message: 'Failed to start pi process' });
return;
}
// Set model if specified
if (model && model !== state.selectedModel) {
state.selectedModel = model;
// Model is set via CLI args on spawn, would need a new process to change
}
// Build context and send prompt
const skillsAppend = await buildSkillsPrompt(ws.data.email);
const skillsAppend = await buildSkillsPrompt(email);
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
send(ws, { type: 'system:prompt', text: contextAppend });
send(session.ws, { type: 'system:prompt', text: contextAppend });
const fullPrompt = `<system>${contextAppend}</system>\n\n${prompt}`;
const rpcCommand: Record<string, unknown> = {
writeRpcCommand(session.piProcess, {
type: 'prompt',
id: `req_${Date.now()}`,
message: fullPrompt,
};
writeRpcCommand(state.piProcess, rpcCommand);
});
}
function handleStop(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (!state?.piProcess) return;
const sessionId = wsToSession.get(ws);
if (!sessionId) return;
const session = sessions.get(sessionId);
if (!session?.piProcess) return;
writeRpcCommand(state.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
writeRpcCommand(session.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
send(ws, { type: 'stopped' });
}
function killPiProcess(state: ConnectionState) {
if (state.piProcess) {
try {
state.piProcess.kill();
} catch {
// already dead
}
state.piProcess = null;
}
}
export const piMonoWebsocket = {
open(ws: ServerWebSocket<WSData>) {
connections.set(ws, {
piProcess: null,
sessionId: null,
pendingTitle: null,
selectedModel: null,
pendingAttachmentIds: [],
cwd: null,
resourceChatDir: null,
logId: null,
fullText: '',
rpcReady: false,
});
// Nothing to do — session is attached when a chat message arrives
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
@@ -431,17 +569,15 @@ export const piMonoWebsocket = {
resourceChatDir: msg.resourceChatDir,
taskInfo: msg.taskInfo,
});
} else if (msg.type === 'resume') {
attachWs(msg.sessionId, ws);
} else if (msg.type === 'stop') {
handleStop(ws);
}
},
close(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (state) {
killPiProcess(state);
}
connections.delete(ws);
detachWs(ws);
},
drain() {},
+283
View File
@@ -1,7 +1,221 @@
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 () => {
@@ -52,3 +266,72 @@ piMonoRouter.post('/install', async (ctx) => {
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);
});