single source of truth for pi config via ~/.pi/agent

auth.json stores api keys, models.json stores local providers directly.
no more copies, sync layers, or per-user config generation.
docker containers mount pi config read-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:42:30 +00:00
co-authored by Claude Opus 4.6
parent 500a70910e
commit 11845fbae5
12 changed files with 302 additions and 580 deletions
+220 -106
View File
@@ -1,15 +1,13 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
import { syncAllUserPiConfigs } from './sync-user-pi-config';
import { PI_CONFIG_DIR } from '../../data-path';
import { invalidateModelCache } from '../pi/list-models';
import { logger } from '../pi/logger';
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');
const ACCESS_POLICY_FILE = join(DATA_PATH, 'pi_access_policy.json');
const PI_AUTH_FILE = join(PI_CONFIG_DIR, 'auth.json');
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
// --- Local provider types ---
@@ -31,18 +29,63 @@ type ProbeResult = {
error?: string;
};
export async function readLocalProviders(): Promise<LocalProvider[]> {
type PiModelConfig = {
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<PiModelConfig> {
try {
const file = Bun.file(LOCAL_PROVIDERS_FILE);
if (!(await file.exists())) return [];
return (await file.json()) as LocalProvider[];
const file = Bun.file(PI_MODELS_FILE);
if (!(await file.exists())) return { providers: {} };
return (await file.json()) as PiModelConfig;
} catch {
return [];
return { providers: {} };
}
}
async function writeLocalProviders(providers: LocalProvider[]) {
await Bun.write(LOCAL_PROVIDERS_FILE, JSON.stringify(providers, null, 2));
async function writeModelsConfig(config: PiModelConfig): Promise<void> {
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<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> {
@@ -151,60 +194,168 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
return { success: false, error: 'Could not detect API type at this URL' };
}
export async function readApiKeys(): Promise<Record<string, string>> {
// --- 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 file = Bun.file(API_KEYS_FILE);
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(PI_AUTH_FILE);
if (!(await file.exists())) return {};
return (await file.json()) as Record<string, string>;
return (await file.json()) as AuthJson;
} catch {
return {};
}
}
async function writeApiKeys(keys: Record<string, string>) {
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
async function writeAuthJson(auth: AuthJson): Promise<void> {
await Bun.write(PI_AUTH_FILE, JSON.stringify(auth, null, 2));
}
export type AccessPolicy = {
allowedModels: string[]; // e.g. ["anthropic:anthropic/claude-sonnet-4"]
};
export async function readAccessPolicy(): Promise<AccessPolicy> {
try {
const file = Bun.file(ACCESS_POLICY_FILE);
if (!(await file.exists())) return { allowedModels: [] };
return (await file.json()) as AccessPolicy;
} catch {
return { allowedModels: [] };
}
}
async function writeAccessPolicy(policy: AccessPolicy) {
await Bun.write(ACCESS_POLICY_FILE, JSON.stringify(policy, null, 2));
}
export 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'] },
/**
* 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 storedKeys = await readApiKeys();
const providers = PROVIDERS.filter((p) =>
p.env.some((e) => storedKeys[e]?.trim() || process.env[e]?.trim()),
).map((p) => p.key);
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 });
});
@@ -214,29 +365,25 @@ const maskValue = (value: string) => {
};
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]!) })),
);
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 { 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 { 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 keys = await readApiKeys();
const auth = await readAuthJson();
if (value.trim()) {
keys[key] = value.trim();
auth[provider] = { type: 'api_key', key: value.trim() };
} else {
delete keys[key];
delete auth[provider];
}
await writeApiKeys(keys);
await writeAuthJson(auth);
invalidateModelCache();
syncAllUserPiConfigs().catch(() => {});
return ctx.json({ ok: true });
});
@@ -310,7 +457,6 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => {
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(),
@@ -320,51 +466,19 @@ piMonoRouter.post('/local-providers', async (ctx) => {
auth: body.auth,
};
providers.push(provider);
await writeLocalProviders(providers);
// Sync to Pi config so Pi knows about this provider
await syncLocalProvidersToPiConfig();
syncAllUserPiConfigs().catch(() => {});
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 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);
// Sync to Pi config to remove this provider
await syncLocalProvidersToPiConfig();
syncAllUserPiConfigs().catch(() => {});
return ctx.json({ ok: true });
});
// --- Access policy ---
piMonoRouter.get('/access-policy', async (ctx) => {
const policy = await readAccessPolicy();
return ctx.json(policy);
});
piMonoRouter.put('/access-policy', async (ctx) => {
const body = await ctx.req.json<AccessPolicy>();
const policy: AccessPolicy = {
allowedModels: Array.isArray(body.allowedModels) ? body.allowedModels : [],
};
await writeAccessPolicy(policy);
await syncAllUserPiConfigs();
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 syncLocalProvidersToPiConfig();
syncAllUserPiConfigs().catch(() => {});
invalidateModelCache();
await refreshLocalProviders();
return ctx.json({ ok: true });
});
@@ -1,149 +0,0 @@
import { join } from 'node:path';
import type { LocalProvider } from './pi-mono';
import { readLocalProviders } from './pi-mono';
import { PI_CONFIG_DIR } from '../../data-path';
import { invalidateModelCache } from '../pi/list-models';
import { logger } from '../pi/logger';
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
type PiModelConfig = {
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;
};
}[];
}>;
};
/**
* Sync Officer's local providers to Pi's models.json config.
* Only local providers are synced — API providers are handled by Pi via env vars.
*/
export async function syncLocalProvidersToPiConfig(): Promise<void> {
try {
const piModelsFile = Bun.file(PI_MODELS_FILE);
let piConfig: PiModelConfig;
if (await piModelsFile.exists()) {
piConfig = await piModelsFile.json();
} else {
piConfig = { providers: {} };
}
// Remove all officer-local-* entries (will be re-added below)
for (const providerId of Object.keys(piConfig.providers)) {
if (providerId.startsWith('officer-local-')) {
delete piConfig.providers[providerId];
}
}
// Add local providers
const localProviders = await readLocalProviders();
logger.info('Syncing local providers to Pi config', { count: localProviders.length });
for (const lp of localProviders) {
const providerId = `officer-local-${lp.id}`;
const models = await fetchModelsFromLocalProvider(lp);
if (models.length === 0) {
logger.warn('No models found for local provider', { provider: lp.name });
continue;
}
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
piConfig.providers[providerId] = {
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 },
})),
};
logger.info('Added local provider to Pi config', { providerId, providerName: lp.name, modelCount: models.length });
}
await Bun.write(PI_MODELS_FILE, JSON.stringify(piConfig, null, 2));
invalidateModelCache();
logger.info('Successfully synced local providers to Pi config');
} catch (err) {
logger.error('Failed to sync local providers to Pi config', { error: String(err) });
}
}
/** Detect reasoning-capable models by name patterns */
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 fetchModelsFromLocalProvider(
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) {
logger.error('Failed to fetch models from local provider', { provider: lp.name, status: res.status });
return [];
}
const data = await res.json();
if (lp.apiType === 'ollama' && data.models) {
return data.models.map((m: any) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
} else if (data.data) {
return data.data.map((m: any) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
}
return [];
} catch (err) {
logger.error('Failed to fetch models from local provider', { provider: lp.name, error: String(err) });
return [];
}
}
@@ -1,175 +0,0 @@
import { join } from 'node:path';
import { mkdir, copyFile, chmod } from 'node:fs/promises';
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono';
import { getUsers } from 'officerdb';
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json');
type PiModelConfig = {
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 };
}[];
}>;
};
type BuildFilteredConfigParams = {
appConfig: PiModelConfig;
allowedModels: string[];
apiKeys: Record<string, string>;
};
/**
* Maps lowercase provider names to their env var names.
* e.g. "openai" → ["OPENAI_API_KEY"], "google" → ["GOOGLE_API_KEY", "GEMINI_API_KEY"]
*/
function buildProviderEnvMap(): Record<string, string[]> {
const map: Record<string, string[]> = {};
for (const p of PROVIDERS) {
map[p.key.toLowerCase()] = p.env;
}
return map;
}
/**
* Build a filtered models.json config with API keys embedded.
* - If allowedModels is empty, all models pass through (same convention as enabledModels).
* - Parse policy key: "anthropic:anthropic/claude-sonnet-4" → provider "anthropic", model id "claude-sonnet-4"
* - Local providers (officer-local-*) pass through unchanged with keys already embedded.
*/
function buildFilteredConfig({ appConfig, allowedModels, apiKeys }: BuildFilteredConfigParams): PiModelConfig {
const envMap = buildProviderEnvMap();
const hasPolicy = allowedModels.length > 0;
// Pre-parse policy into a map: provider → Set<modelId>
const policyMap = new Map<string, Set<string>>();
if (hasPolicy) {
for (const key of allowedModels) {
const colonIdx = key.indexOf(':');
if (colonIdx === -1) continue;
const provider = key.slice(0, colonIdx);
const fullModelId = key.slice(colonIdx + 1);
// Strip "provider/" prefix to get the models.json model id
const slashIdx = fullModelId.indexOf('/');
const modelId = slashIdx !== -1 ? fullModelId.slice(slashIdx + 1) : fullModelId;
if (!policyMap.has(provider)) policyMap.set(provider, new Set());
policyMap.get(provider)!.add(modelId);
}
}
const result: PiModelConfig = { providers: {} };
for (const [providerId, providerConfig] of Object.entries(appConfig.providers)) {
// Local providers pass through unchanged
if (providerId.startsWith('officer-local-')) {
result.providers[providerId] = providerConfig;
continue;
}
const providerLower = providerId.toLowerCase();
// Filter models if policy is set
let models = providerConfig.models;
if (hasPolicy) {
const allowedSet = policyMap.get(providerLower);
if (!allowedSet) continue; // provider not in policy at all
models = models.filter((m) => allowedSet.has(m.id));
if (models.length === 0) continue;
}
// Embed API key
let apiKey = providerConfig.apiKey;
const envVars = envMap[providerLower];
if (envVars) {
for (const envVar of envVars) {
const value = apiKeys[envVar];
if (value?.trim()) {
apiKey = value.trim();
break;
}
}
}
result.providers[providerId] = { ...providerConfig, models, apiKey };
}
return result;
}
async function readAppModelsConfig(): Promise<PiModelConfig> {
try {
const file = Bun.file(PI_MODELS_FILE);
if (!(await file.exists())) return { providers: {} };
return (await file.json()) as PiModelConfig;
} catch {
return { providers: {} };
}
}
export async function syncUserPiConfig(email: string): Promise<void> {
const [appConfig, policy, apiKeys] = await Promise.all([
readAppModelsConfig(),
readAccessPolicy(),
readApiKeys(),
]);
const filtered = buildFilteredConfig({
appConfig,
allowedModels: policy.allowedModels,
apiKeys,
});
const userDir = getUserPiConfigDir(email);
const sessionsDir = join(userDir, 'sessions');
await mkdir(sessionsDir, { recursive: true });
await chmod(userDir, 0o777).catch(() => {});
await chmod(sessionsDir, 0o777).catch(() => {});
await Bun.write(join(userDir, 'models.json'), JSON.stringify(filtered, null, 2));
// Copy settings.json from app-level Pi config
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
}
export async function syncAllUserPiConfigs(): Promise<void> {
const users = await getUsers();
if (users.length === 0) return;
const [appConfig, policy, apiKeys] = await Promise.all([
readAppModelsConfig(),
readAccessPolicy(),
readApiKeys(),
]);
const filtered = buildFilteredConfig({
appConfig,
allowedModels: policy.allowedModels,
apiKeys,
});
const configJson = JSON.stringify(filtered, null, 2);
for (const user of users) {
try {
const userDir = getUserPiConfigDir(user.email);
await mkdir(userDir, { recursive: true });
await Bun.write(join(userDir, 'models.json'), configJson);
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
} catch (err) {
console.error(`[sync] Failed to sync Pi config for ${user.email}:`, err);
}
}
console.log(`[bootstrap] Synced Pi config for ${users.length} user(s)`);
}