Pi running inside the docker containers
This commit is contained in:
@@ -2,12 +2,14 @@ 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 { invalidateModelCache } from '../pi/list-models';
|
||||
|
||||
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');
|
||||
|
||||
// --- Local provider types ---
|
||||
|
||||
@@ -163,7 +165,25 @@ async function writeApiKeys(keys: Record<string, string>) {
|
||||
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
||||
}
|
||||
|
||||
const PROVIDERS: { key: string; env: string[] }[] = [
|
||||
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'] },
|
||||
@@ -216,6 +236,7 @@ piMonoRouter.put('/api-keys', async (ctx) => {
|
||||
}
|
||||
await writeApiKeys(keys);
|
||||
invalidateModelCache();
|
||||
syncAllUserPiConfigs().catch(() => {});
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -304,7 +325,8 @@ piMonoRouter.post('/local-providers', async (ctx) => {
|
||||
|
||||
// Sync to Pi config so Pi knows about this provider
|
||||
await syncLocalProvidersToPiConfig();
|
||||
|
||||
syncAllUserPiConfigs().catch(() => {});
|
||||
|
||||
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
||||
});
|
||||
|
||||
@@ -317,7 +339,25 @@ piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
||||
|
||||
// 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();
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, copyFile } from 'node:fs/promises';
|
||||
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
|
||||
import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono';
|
||||
import { officerdb, Users } 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);
|
||||
await mkdir(userDir, { recursive: true });
|
||||
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 officerdb.select({ email: Users.email }).from(Users);
|
||||
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)`);
|
||||
}
|
||||
Reference in New Issue
Block a user