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');