PI-MONO
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user