chat: rename the pi-mono provider router + purge residual pi names (Stage 4b/2)

Renames the AI-harness/provider settings router into the chat namespace and
clears the remaining "pi" identifiers from the chat stack.

- server-settings/pi-mono.ts → chat-providers.ts; piMonoRouter → chatProvidersRouter;
  route /server-settings/pi-mono → /server-settings/chat-providers (+ all callers)
- piId → providerId (PROVIDERS map + AIHarnessesSection UI), PiProvider → ChatProvider,
  PI_MONO_* query keys → CHAT_PROVIDERS_*, installPiMono → installAgent
- data-path: PI_CONFIG_DIR → AGENT_CONFIG_DIR (path ~/.pi/agent unchanged);
  drop dead getPiMonoDir/getPiMonoSessionDir exports
- settings: flip the vestigial defaultProvider literal 'pi' → 'chat' (never read;
  only defaultModel drives behavior); access-policy config key 'pi-access-policy'
  → 'chat-access-policy'
- misc: ModelSelector fallback label, TaskDefaults model grouping, CapabilityPage
  chat var, a stale stream-parser comment

Intentionally left (genuine external `pi`/opencode references, not ours to rename):
the `pi` binary install/version flow (@mariozechner/pi-coding-agent, `which pi`),
the ~/.pi/agent config path, PI_TOOLS_DIRS/PI_SEARXNG_URL runtime env-var contract,
TOOL.md `targets: pi` metadata, and the "Pi Mono" installer UI label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 15:52:58 +00:00
co-authored by Claude Opus 4.8
parent 49df1c0b0c
commit 669692355d
15 changed files with 140 additions and 144 deletions
@@ -0,0 +1,631 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
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 chatProvidersRouter = createRouter();
const AUTH_FILE = join(AGENT_CONFIG_DIR, 'auth.json');
const MODELS_FILE = join(AGENT_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 ModelConfig = {
providers: Record<
string,
{
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}
>;
};
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<ModelConfig> {
try {
const file = Bun.file(MODELS_FILE);
if (!(await file.exists())) return { providers: {} };
return (await file.json()) as ModelConfig;
} catch {
return { providers: {} };
}
}
async function writeModelsConfig(config: ModelConfig): Promise<void> {
await Bun.write(MODELS_FILE, JSON.stringify(config, null, 2));
}
/**
* Read local providers from models.json by extracting officer-local-* entries.
*/
export async function readLocalProviders(): Promise<LocalProvider[]> {
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<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' };
}
// --- 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<string, string> = {};
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<void> {
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<boolean> {
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<void> {
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<string, AuthEntry>;
async function readAuthJson(): Promise<AuthJson> {
try {
const file = Bun.file(AUTH_FILE);
if (!(await file.exists())) return {};
return (await file.json()) as AuthJson;
} catch {
return {};
}
}
async function writeAuthJson(auth: AuthJson): Promise<void> {
await Bun.write(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; 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' },
];
chatProvidersRouter.get('/providers', async (ctx) => {
return ctx.json(PROVIDERS);
});
chatProvidersRouter.get('/auth', async (ctx) => {
const auth = await readAuthJson();
const providers = PROVIDERS.filter((p) => auth[p.providerId]?.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);
};
chatProvidersRouter.get('/api-keys', async (ctx) => {
const auth = await readAuthJson();
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 });
});
chatProvidersRouter.put('/api-keys', async (ctx) => {
const { provider, value } = await ctx.req.json<{ provider: string; value: string }>();
if (!PROVIDERS.some((p) => p.providerId === 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 = 'chat-access-policy';
chatProvidersRouter.get('/access-policy', async (ctx) => {
const policy = await readConfigValue<AccessPolicy>(ACCESS_POLICY_KEY, { allowedModels: [] });
return ctx.json(policy);
});
chatProvidersRouter.put('/access-policy', async (ctx) => {
const body = await ctx.req.json<AccessPolicy>();
await writeConfigValue(ACCESS_POLICY_KEY, body);
return ctx.json(body);
});
const REMOTE_HEALTH_CONFIG: Record<
string,
{
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}
> = {
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}` }) },
opencode: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
};
chatProvidersRouter.get('/api-keys/health', async (ctx) => {
const auth = await readAuthJson();
const results: Record<string, boolean | null> = {};
const checks = PROVIDERS.filter((p) => auth[p.providerId]?.key?.trim()).map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.providerId];
if (!config) {
results[p.providerId] = null;
return;
}
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.providerId] = res.ok;
} catch {
results[p.providerId] = false;
} finally {
clearTimeout(timer);
}
});
await Promise.all(checks);
return ctx.json(results);
});
/**
* Resolve the full path to the `pi` binary.
* Checks PATH first, then falls back to the npm global bin directory
* (which may not be in PATH when the server is managed by pm2).
*/
/**
* Finds the Pi package directory (containing package.json).
* Checks: PATH → npm global prefix → ~/.npm-global fallback.
*/
async function resolvePiPackageDir(): Promise<string | null> {
const candidates: string[] = [];
// 1. Try PATH
try {
const proc = Bun.spawn(['which', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = (await new Response(proc.stdout).text()).trim();
await proc.exited;
if (proc.exitCode === 0 && output) {
// Resolve symlink: bin/pi -> ../lib/node_modules/.../dist/cli.js
const resolved = (await Bun.file(output).exists()) ? output : null;
if (resolved) {
// Walk up from bin to find the package
const npmGlobalLib = join(output, '..', '..', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
candidates.push(npmGlobalLib);
}
}
} catch {}
// 2. Common locations
const home = process.env.HOME ?? '';
candidates.push(
join(home, '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'),
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
);
for (const dir of candidates) {
const pkgFile = join(dir, 'package.json');
if (await Bun.file(pkgFile).exists()) return dir;
}
return null;
}
/** Read Pi version directly from its package.json — avoids shebang/spawn issues. */
async function getPiVersion(): Promise<{ version: string | null; path: string | null }> {
const dir = await resolvePiPackageDir();
if (!dir) return { version: null, path: null };
try {
const pkg = await Bun.file(join(dir, 'package.json')).json();
return { version: pkg.version ?? null, path: dir };
} catch {
return { version: null, path: dir };
}
}
chatProvidersRouter.get('/version', async (ctx) => {
const { version, path } = await getPiVersion();
return ctx.json({ version, path, globalPath: path });
});
chatProvidersRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(proc.stderr).text();
await proc.exited;
if (proc.exitCode !== 0) {
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const { version, path } = await getPiVersion();
if (!version)
return ctx.json({ version: null, path, globalPath: path, error: 'Installed but package.json not found' }, 500);
return ctx.json({ version, path, globalPath: path });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
// --- Local providers ---
chatProvidersRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(
providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})),
);
});
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);
});
chatProvidersRouter.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 });
});
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 });
});
chatProvidersRouter.post('/local-providers/refresh', async (ctx) => {
await refreshLocalProviders();
return ctx.json({ ok: true });
});
chatProvidersRouter.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);
});