diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
index 20568cc8..30a0a7f9 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx
@@ -15,7 +15,7 @@ import {
import { useClient } from 'hooks/useClient';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
-type StoredApiKeys = { keys: { env: string; value: string }[] };
+type StoredApiKeys = { keys: { provider: string; value: string }[] };
type LocalProviderEntry = {
id: string;
name: string;
@@ -33,31 +33,21 @@ type ProbeResult = {
error?: string;
};
-const PI_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'] },
+const PI_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' },
];
-const TEXT_FIELDS = new Set([
- 'AWS_REGION',
- 'GOOGLE_APPLICATION_CREDENTIALS',
- 'GOOGLE_CLOUD_PROJECT',
- 'GOOGLE_CLOUD_LOCATION',
- 'AZURE_OPENAI_BASE_URL',
-]);
-
type ProbeState =
| { step: 'url' }
| { step: 'probing' }
@@ -117,24 +107,25 @@ export const AIHarnessesSection = () => {
setAuthPassword('');
};
- const storedEnvs = new Set(piMonoKeys?.keys.map((k: { env: string }) => k.env) ?? []);
- const connectedProviders = PI_PROVIDERS.filter((p) => p.env.some((e) => storedEnvs.has(e)));
- const unconnectedProviders = PI_PROVIDERS.filter((p) => !p.env.some((e) => storedEnvs.has(e)));
+ const storedKeys: StoredApiKeys['keys'] = piMonoKeys?.keys ?? [];
+ const storedPiIds = new Set(storedKeys.map((k) => k.provider));
+ const connectedProviders = PI_PROVIDERS.filter((p) => storedPiIds.has(p.piId));
+ const unconnectedProviders = PI_PROVIDERS.filter((p) => !storedPiIds.has(p.piId));
- const getStoredMasked = (env: string) =>
- piMonoKeys?.keys.find((k: { env: string; value: string }) => k.env === env)?.value ?? '';
+ const getStoredMasked = (piId: string) =>
+ storedKeys.find((k) => k.provider === piId)?.value ?? '';
- const saveApiKey = async (env: string) => {
- const value = keyInputs[env];
+ const saveApiKey = async (piId: string) => {
+ const value = keyInputs[piId];
if (value === undefined) return;
- setSavingKey(env);
+ setSavingKey(piId);
try {
- await client.put('/server-settings/pi-mono/api-keys', { key: env, value });
+ await client.put('/server-settings/pi-mono/api-keys', { provider: piId, value });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
setKeyInputs((prev) => {
const next = { ...prev };
- delete next[env];
+ delete next[piId];
return next;
});
} finally {
@@ -143,9 +134,7 @@ export const AIHarnessesSection = () => {
};
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
- for (const env of provider.env) {
- await client.put('/server-settings/pi-mono/api-keys', { key: env, value: '' });
- }
+ await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] });
if (editingProvider === provider.key) setEditingProvider(null);
@@ -433,69 +422,57 @@ export const AIHarnessesSection = () => {
Remote Providers
{connectedProviders.map((provider) => (
-
-
- {provider.key}
-
-
- {provider.env.map((env) => (
-
-
- setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
- />
-
-
- ))}
+
+
+ setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
+ />
+
+
))}
{editingProvider && (() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.includes(provider)) return null;
return (
-
-
{provider.key}
- {provider.env.map((env) => (
-
-
- setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
- autoFocus={env === provider.env[0]}
- />
-
-
- ))}
+
+
+ setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))}
+ autoFocus
+ />
+
);
})()}
diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts
index 2a122943..bdcef158 100644
--- a/src/servers/api/auth/bootstrap.ts
+++ b/src/servers/api/auth/bootstrap.ts
@@ -4,7 +4,6 @@ import { getUserCount, createUser } from 'officerdb';
import { sign, verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
-import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
import { validatePassword } from './validate-password';
export const bootstrapHandler: Handler = async function (ctx) {
@@ -58,6 +57,5 @@ export const bootstrapHandler: Handler = async function (ctx) {
status: 'Active',
});
- syncUserPiConfig(payload.email).catch(() => {});
return ctx.json({ ok: true });
};
diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts
index 1bb0fdaa..639affb3 100755
--- a/src/servers/api/auth/signin.ts
+++ b/src/servers/api/auth/signin.ts
@@ -4,7 +4,6 @@ import { join } from 'node:path';
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
-import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -27,7 +26,6 @@ export const signinHandler: Handler = async function (ctx) {
const { id, name, username, role } = dbUser;
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
- syncUserPiConfig(email).catch(() => {});
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
diff --git a/src/servers/api/pi/list-models.ts b/src/servers/api/pi/list-models.ts
index a4272b6e..dc25f464 100644
--- a/src/servers/api/pi/list-models.ts
+++ b/src/servers/api/pi/list-models.ts
@@ -24,7 +24,7 @@ const parseSize = (s?: string): number => {
return Math.round(num);
};
-export async function listPiModels(envKeys: Record
): Promise {
+export async function listPiModels(): Promise {
if (cachedModels && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedModels;
}
@@ -33,7 +33,7 @@ export async function listPiModels(envKeys: Record): Promise f !== '--extension').length,
});
} else {
- const storedKeys = await readApiKeys();
const searxng = await readSearxngConfig();
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
@@ -308,7 +301,6 @@ export async function spawnPi(
stderr: 'pipe',
env: {
...process.env,
- ...storedKeys,
HOME: getHomeDir(email),
OFFICER_USER_HOME: getHomeDir(email),
OFFICER_USER_ROOT: join(DATA_PATH, email),
diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts
index bce1ed7b..f82408f8 100644
--- a/src/servers/api/pi/rest.ts
+++ b/src/servers/api/pi/rest.ts
@@ -1,7 +1,7 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
-import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
+import { readLocalProviders } from '../server-settings/pi-mono';
import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models';
import { getHomeDir } from '../../data-path';
@@ -21,8 +21,7 @@ export const piRestRouter = createRouter();
*/
piRestRouter.get('/pi/models', async (ctx: Context) => {
try {
- const storedKeys = await readApiKeys();
- const models = await listPiModels(storedKeys);
+ const models = await listPiModels();
// Build providerNames map for officer-local-* providers
const providerNames: Record = {};
diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts
index 41b9552e..2641ede9 100644
--- a/src/servers/api/server-settings/pi-mono.ts
+++ b/src/servers/api/server-settings/pi-mono.ts
@@ -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 {
+type PiModelConfig = {
+ providers: Record;
+};
+
+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 {
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 {
+ 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 {
+ 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 {
@@ -151,60 +194,168 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise> {
+// --- 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 = {};
+ 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 {
+ 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 {
+ 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 {
+ 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;
+
+async function readAuthJson(): Promise {
+ try {
+ const file = Bun.file(PI_AUTH_FILE);
if (!(await file.exists())) return {};
- return (await file.json()) as Record;
+ return (await file.json()) as AuthJson;
} catch {
return {};
}
}
-async function writeApiKeys(keys: Record) {
- await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
+async function writeAuthJson(auth: AuthJson): Promise {
+ 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 {
- 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();
- 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 });
});
diff --git a/src/servers/api/server-settings/sync-pi-config.ts b/src/servers/api/server-settings/sync-pi-config.ts
deleted file mode 100644
index e53aadee..00000000
--- a/src/servers/api/server-settings/sync-pi-config.ts
+++ /dev/null
@@ -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;
-};
-
-/**
- * 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 {
- 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 = {};
- 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 [];
- }
-}
diff --git a/src/servers/api/server-settings/sync-user-pi-config.ts b/src/servers/api/server-settings/sync-user-pi-config.ts
deleted file mode 100644
index 36090b22..00000000
--- a/src/servers/api/server-settings/sync-user-pi-config.ts
+++ /dev/null
@@ -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;
-};
-
-type BuildFilteredConfigParams = {
- appConfig: PiModelConfig;
- allowedModels: string[];
- apiKeys: Record;
-};
-
-/**
- * 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 {
- const map: Record = {};
- 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
- const policyMap = new Map>();
- 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 {
- 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 {
- 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 {
- 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)`);
-}
diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts
index 70e52996..e9fd08e2 100644
--- a/src/servers/api/terminal/websocket.ts
+++ b/src/servers/api/terminal/websocket.ts
@@ -3,8 +3,7 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR } from '@@/data-path';
-import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
+import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path';
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
@@ -191,6 +190,7 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
+ '-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
...googleMounts,
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
@@ -349,9 +349,6 @@ export const initTerminalSidecars = async () => {
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
try {
- await syncUserPiConfig(user.email).catch((err) => {
- console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err);
- });
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
console.log(`[terminal] sidecar ready for ${user.email}`);
} catch (err) {
diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts
index b0e9e7d9..94a06c43 100644
--- a/src/servers/bootstrap.ts
+++ b/src/servers/bootstrap.ts
@@ -1,9 +1,5 @@
-import { mkdirSync, existsSync, copyFileSync } from 'node:fs';
-import { join } from 'node:path';
-import { homedir } from 'node:os';
-import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
-import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
-import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
+import { mkdirSync } from 'node:fs';
+import { DATA_PATH } from './data-path';
import { syncSeedSkills } from './sync-skills';
import { syncSeedTools } from './sync-tools';
import { syncSeedExtensions } from './sync-extensions';
@@ -13,7 +9,6 @@ import { generateResourceSkill } from './api/pi/pi-bridge';
import { initQueue } from './queue';
mkdirSync(DATA_PATH, { recursive: true });
-mkdirSync(PI_CONFIG_DIR, { recursive: true });
async function ensurePiInstalled(): Promise {
try {
@@ -46,22 +41,6 @@ async function installPi(): Promise {
}
}
-function seedPiConfig(): void {
- const hostPiDir = join(homedir(), '.pi', 'agent');
- const filesToCopy = ['models.json', 'settings.json'];
-
- for (const file of filesToCopy) {
- const target = join(PI_CONFIG_DIR, file);
- if (existsSync(target)) continue;
-
- const source = join(hostPiDir, file);
- if (existsSync(source)) {
- copyFileSync(source, target);
- console.log(`[bootstrap] Seeded ${file} from host Pi config`);
- }
- }
-}
-
(async () => {
const installed = await ensurePiInstalled();
if (!installed) {
@@ -72,7 +51,6 @@ function seedPiConfig(): void {
}
}
- seedPiConfig();
syncSeedSkills();
syncSeedTools();
syncSeedExtensions();
@@ -80,14 +58,6 @@ function seedPiConfig(): void {
await migrateSettingsToResources();
generateResourceSkill(DATA_PATH);
- await syncLocalProvidersToPiConfig().catch(err => {
- console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
- });
-
- await syncAllUserPiConfigs().catch(err => {
- console.error('[bootstrap] Failed to sync user Pi configs:', err);
- });
-
await initQueue().catch(err => {
console.error('[bootstrap] Failed to initialize queue:', err);
});
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index 2e48c679..6b527e39 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -1,10 +1,11 @@
import { join, resolve } from 'node:path';
+import { homedir } from 'node:os';
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
export const SERVER_CONFIG_DIR = join(DATA_PATH, 'server-settings');
-export const PI_CONFIG_DIR = join(DATA_PATH, 'pi-config');
+export const PI_CONFIG_DIR = join(homedir(), '.pi', 'agent');
export const SEED_PATH = resolve(import.meta.dir, '../../seed');