|
|
|
@@ -1,14 +1,14 @@
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { createRouter } from '../../create-router';
|
|
|
|
|
import { PI_CONFIG_DIR } from '../../data-path';
|
|
|
|
|
import { AGENT_CONFIG_DIR } from '../../data-path';
|
|
|
|
|
import { invalidateModelCache } from '../chat/list-models';
|
|
|
|
|
import { logger } from '../chat/logger';
|
|
|
|
|
import { readConfigValue, writeConfigValue } from 'officerdb';
|
|
|
|
|
|
|
|
|
|
export const piMonoRouter = createRouter();
|
|
|
|
|
export const chatProvidersRouter = createRouter();
|
|
|
|
|
|
|
|
|
|
const PI_AUTH_FILE = join(PI_CONFIG_DIR, 'auth.json');
|
|
|
|
|
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
|
|
|
|
const AUTH_FILE = join(AGENT_CONFIG_DIR, 'auth.json');
|
|
|
|
|
const MODELS_FILE = join(AGENT_CONFIG_DIR, 'models.json');
|
|
|
|
|
|
|
|
|
|
// --- Local provider types ---
|
|
|
|
|
|
|
|
|
@@ -30,7 +30,7 @@ type ProbeResult = {
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type PiModelConfig = {
|
|
|
|
|
type ModelConfig = {
|
|
|
|
|
providers: Record<
|
|
|
|
|
string,
|
|
|
|
|
{
|
|
|
|
@@ -58,18 +58,18 @@ type OfficerMeta = {
|
|
|
|
|
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function readModelsConfig(): Promise<PiModelConfig> {
|
|
|
|
|
async function readModelsConfig(): Promise<ModelConfig> {
|
|
|
|
|
try {
|
|
|
|
|
const file = Bun.file(PI_MODELS_FILE);
|
|
|
|
|
const file = Bun.file(MODELS_FILE);
|
|
|
|
|
if (!(await file.exists())) return { providers: {} };
|
|
|
|
|
return (await file.json()) as PiModelConfig;
|
|
|
|
|
return (await file.json()) as ModelConfig;
|
|
|
|
|
} catch {
|
|
|
|
|
return { providers: {} };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeModelsConfig(config: PiModelConfig): Promise<void> {
|
|
|
|
|
await Bun.write(PI_MODELS_FILE, JSON.stringify(config, null, 2));
|
|
|
|
|
async function writeModelsConfig(config: ModelConfig): Promise<void> {
|
|
|
|
|
await Bun.write(MODELS_FILE, JSON.stringify(config, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@@ -333,7 +333,7 @@ type AuthJson = Record<string, AuthEntry>;
|
|
|
|
|
|
|
|
|
|
async function readAuthJson(): Promise<AuthJson> {
|
|
|
|
|
try {
|
|
|
|
|
const file = Bun.file(PI_AUTH_FILE);
|
|
|
|
|
const file = Bun.file(AUTH_FILE);
|
|
|
|
|
if (!(await file.exists())) return {};
|
|
|
|
|
return (await file.json()) as AuthJson;
|
|
|
|
|
} catch {
|
|
|
|
@@ -342,7 +342,7 @@ async function readAuthJson(): Promise<AuthJson> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeAuthJson(auth: AuthJson): Promise<void> {
|
|
|
|
|
await Bun.write(PI_AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
|
|
|
await Bun.write(AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@@ -350,29 +350,29 @@ async function writeAuthJson(auth: AuthJson): Promise<void> {
|
|
|
|
|
* 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: 'opencode' },
|
|
|
|
|
{ key: 'ZAI', piId: 'zai' },
|
|
|
|
|
{ key: 'Cerebras', piId: 'cerebras' },
|
|
|
|
|
export const PROVIDERS: { key: string; providerId: string }[] = [
|
|
|
|
|
{ key: 'Anthropic', providerId: 'anthropic' },
|
|
|
|
|
{ key: 'OpenAI', providerId: 'openai' },
|
|
|
|
|
{ key: 'Google', providerId: 'google' },
|
|
|
|
|
{ key: 'Groq', providerId: 'groq' },
|
|
|
|
|
{ key: 'Mistral', providerId: 'mistral' },
|
|
|
|
|
{ key: 'xAI', providerId: 'xai' },
|
|
|
|
|
{ key: 'OpenRouter', providerId: 'openrouter' },
|
|
|
|
|
{ key: 'MiniMax', providerId: 'minimax' },
|
|
|
|
|
{ key: 'Hugging Face', providerId: 'huggingface' },
|
|
|
|
|
{ key: 'Azure OpenAI', providerId: 'azure-openai-responses' },
|
|
|
|
|
{ key: 'OpenCode Zen', providerId: 'opencode' },
|
|
|
|
|
{ key: 'ZAI', providerId: 'zai' },
|
|
|
|
|
{ key: 'Cerebras', providerId: 'cerebras' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/providers', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/providers', async (ctx) => {
|
|
|
|
|
return ctx.json(PROVIDERS);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/auth', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/auth', async (ctx) => {
|
|
|
|
|
const auth = await readAuthJson();
|
|
|
|
|
const providers = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => p.key);
|
|
|
|
|
const providers = PROVIDERS.filter((p) => auth[p.providerId]?.key?.trim()).map((p) => p.key);
|
|
|
|
|
return ctx.json({ authenticated: providers.length > 0, providers });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -381,18 +381,18 @@ const maskValue = (value: string) => {
|
|
|
|
|
return value.slice(0, 3) + '...' + value.slice(-3);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/api-keys', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.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),
|
|
|
|
|
const keys = PROVIDERS.filter((p) => auth[p.providerId]?.key?.trim()).map((p) => ({
|
|
|
|
|
provider: p.providerId,
|
|
|
|
|
value: maskValue(auth[p.providerId]!.key),
|
|
|
|
|
}));
|
|
|
|
|
return ctx.json({ keys });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.put('/api-keys', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.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);
|
|
|
|
|
if (!PROVIDERS.some((p) => p.providerId === provider)) return ctx.json({ error: 'Invalid provider' }, 400);
|
|
|
|
|
|
|
|
|
|
const auth = await readAuthJson();
|
|
|
|
|
if (value.trim()) {
|
|
|
|
@@ -406,14 +406,14 @@ piMonoRouter.put('/api-keys', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
type AccessPolicy = { allowedModels: string[] };
|
|
|
|
|
const ACCESS_POLICY_KEY = 'pi-access-policy';
|
|
|
|
|
const ACCESS_POLICY_KEY = 'chat-access-policy';
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/access-policy', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/access-policy', async (ctx) => {
|
|
|
|
|
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
|
|
|
|
|
return ctx.json(policy);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.put('/access-policy', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.put('/access-policy', async (ctx) => {
|
|
|
|
|
const body = await ctx.req.json<AccessPolicy>();
|
|
|
|
|
await writeConfigValue(ACCESS_POLICY_KEY, body);
|
|
|
|
|
return ctx.json(body);
|
|
|
|
@@ -441,26 +441,26 @@ const REMOTE_HEALTH_CONFIG: Record<
|
|
|
|
|
zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/api-keys/health', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/api-keys/health', async (ctx) => {
|
|
|
|
|
const auth = await readAuthJson();
|
|
|
|
|
const results: Record<string, boolean | null> = {};
|
|
|
|
|
|
|
|
|
|
const checks = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map(async (p) => {
|
|
|
|
|
const config = REMOTE_HEALTH_CONFIG[p.piId];
|
|
|
|
|
const checks = PROVIDERS.filter((p) => auth[p.providerId]?.key?.trim()).map(async (p) => {
|
|
|
|
|
const config = REMOTE_HEALTH_CONFIG[p.providerId];
|
|
|
|
|
if (!config) {
|
|
|
|
|
results[p.piId] = null;
|
|
|
|
|
results[p.providerId] = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const key = auth[p.piId]!.key;
|
|
|
|
|
const key = auth[p.providerId]!.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;
|
|
|
|
|
results[p.providerId] = res.ok;
|
|
|
|
|
} catch {
|
|
|
|
|
results[p.piId] = false;
|
|
|
|
|
results[p.providerId] = false;
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
}
|
|
|
|
@@ -524,12 +524,12 @@ async function getPiVersion(): Promise<{ version: string | null; path: string |
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/version', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/version', async (ctx) => {
|
|
|
|
|
const { version, path } = await getPiVersion();
|
|
|
|
|
return ctx.json({ version, path, globalPath: path });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.post('/install', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.post('/install', async (ctx) => {
|
|
|
|
|
try {
|
|
|
|
|
const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
@@ -552,7 +552,7 @@ piMonoRouter.post('/install', async (ctx) => {
|
|
|
|
|
|
|
|
|
|
// --- Local providers ---
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/local-providers', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/local-providers', async (ctx) => {
|
|
|
|
|
const providers = await readLocalProviders();
|
|
|
|
|
return ctx.json(
|
|
|
|
|
providers.map((p) => ({
|
|
|
|
@@ -562,14 +562,14 @@ piMonoRouter.get('/local-providers', async (ctx) => {
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.post('/local-providers/probe', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.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) => {
|
|
|
|
|
chatProvidersRouter.post('/local-providers', async (ctx) => {
|
|
|
|
|
const body = await ctx.req.json<{
|
|
|
|
|
url: string;
|
|
|
|
|
name?: string;
|
|
|
|
@@ -589,19 +589,19 @@ piMonoRouter.post('/local-providers', async (ctx) => {
|
|
|
|
|
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.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) => {
|
|
|
|
|
chatProvidersRouter.post('/local-providers/refresh', async (ctx) => {
|
|
|
|
|
await refreshLocalProviders();
|
|
|
|
|
return ctx.json({ ok: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
piMonoRouter.get('/local-providers/health', async (ctx) => {
|
|
|
|
|
chatProvidersRouter.get('/local-providers/health', async (ctx) => {
|
|
|
|
|
const providers = await readLocalProviders();
|
|
|
|
|
const results: Record<string, boolean> = {};
|
|
|
|
|
|