From f1e3ce4b76ea6d4b72987b57721cdf6a6976d27a Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Sun, 22 Feb 2026 20:19:11 +0000 Subject: [PATCH] new flow for Pi provider/model settings --- .../ServerSettings/AIHarnessesSection.tsx | 4 + .../Dashboard/Settings/SystemSettings.tsx | 24 ++- src/servers/api/pi/list-models.ts | 100 +++++++++ src/servers/api/pi/pi-bridge.ts | 3 +- src/servers/api/pi/rest.ts | 132 +----------- src/servers/api/pi/types.ts | 2 + src/servers/api/server-settings/pi-mono.ts | 2 + .../api/server-settings/provider-registry.ts | 194 ------------------ .../api/server-settings/sync-pi-config.ts | 132 ++---------- src/servers/bootstrap.ts | 74 ++++++- src/servers/data-path.ts | 2 + .../officerdev/src/apps/Chat/types.ts | 2 + .../src/apps/ChatHistory/SessionList.tsx | 4 +- src/workspaces/state/src/useModels.ts | 31 ++- src/workspaces/state/src/useSettings.ts | 3 + 15 files changed, 249 insertions(+), 460 deletions(-) create mode 100644 src/servers/api/pi/list-models.ts delete mode 100644 src/servers/api/server-settings/provider-registry.ts 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 c099a7b7..25b8abcf 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -131,6 +131,7 @@ export const AIHarnessesSection = () => { try { await client.put('/server-settings/pi-mono/api-keys', { key: env, value }); queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] }); setKeyInputs((prev) => { const next = { ...prev }; delete next[env]; @@ -146,6 +147,7 @@ export const AIHarnessesSection = () => { await client.put('/server-settings/pi-mono/api-keys', { key: env, value: '' }); } queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] }); if (editingProvider === provider.key) setEditingProvider(null); }; @@ -225,6 +227,7 @@ export const AIHarnessesSection = () => { auth, }); queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] }); toast.success(`Connected to ${probe.name}`); resetLocalForm(); } catch { @@ -236,6 +239,7 @@ export const AIHarnessesSection = () => { const removeLocalProvider = async (id: string) => { await client.delete(`/server-settings/pi-mono/local-providers/${id}`); queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] }); + queryClient.invalidateQueries({ queryKey: ['PI_MODELS'] }); }; return ( diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 23ecf09b..2a31e6ca 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -16,7 +16,7 @@ import { TerminalView } from 'officerdev'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; import { useSettings } from 'state/useSettings'; import { useUserState } from 'state/useUserState'; -import { usePiModels, useVisiblePiModels, type ModelOption } from 'state/useModels'; +import { usePiModels, useVisiblePiModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels'; import type { UserSettings } from 'state/useSettings'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; @@ -39,7 +39,7 @@ const PROVIDER_DISPLAY: Record = { }; function displayProviderName(provider: string): string { - return PROVIDER_DISPLAY[provider] ?? provider; + return PROVIDER_DISPLAY[provider] ?? getProviderDisplayName(provider); } const groups: SettingsSectionGroup[] = [ @@ -351,11 +351,14 @@ function ModelVisibilitySection() { const { enabled, disabled } = useMemo(() => { if (!currentGroup) return { enabled: [], disabled: [] }; + const allEnabled = enabledModels.length === 0; + const enabledProviderSet = new Set(enabledModels.map((key) => key.split(':')[0])); + const isNewProvider = !allEnabled && !enabledProviderSet.has(currentGroup.provider); const en: { id: string; name: string; key: string }[] = []; const dis: { id: string; name: string; key: string }[] = []; for (const m of currentGroup.models) { const key = `${currentGroup.provider}:${m.id}`; - if (enabledModels.includes(key)) { + if (allEnabled || isNewProvider || enabledModels.includes(key)) { en.push({ ...m, key }); } else { dis.push({ ...m, key }); @@ -374,9 +377,20 @@ function ModelVisibilitySection() { const disableModel = useCallback( async (key: string) => { - await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: enabledModels.filter((id) => id !== key) } }); + let base = enabledModels; + if (base.length === 0) { + base = piModels.map((m) => modelKey(m)); + } else { + const provider = key.split(':')[0]!; + const knownProviders = new Set(base.map((k) => k.split(':')[0])); + if (!knownProviders.has(provider)) { + const newProviderKeys = piModels.filter((m) => m.provider === provider).map((m) => modelKey(m)); + base = [...base, ...newProviderKeys]; + } + } + await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: base.filter((id) => id !== key) } }); }, - [settings, enabledModels, saveSettings], + [settings, enabledModels, piModels, saveSettings], ); const onDragStart = useCallback((ev: DragEvent, key: string) => { diff --git a/src/servers/api/pi/list-models.ts b/src/servers/api/pi/list-models.ts new file mode 100644 index 00000000..a4272b6e --- /dev/null +++ b/src/servers/api/pi/list-models.ts @@ -0,0 +1,100 @@ +import { PI_CONFIG_DIR } from '../../data-path'; +import { logger } from './logger'; +import type { ModelInfo } from './types'; + +const CACHE_TTL_MS = 60_000; + +let cachedModels: ModelInfo[] | null = null; +let cacheTimestamp = 0; + +export function invalidateModelCache(): void { + cachedModels = null; + cacheTimestamp = 0; +} + +const parseSize = (s?: string): number => { + if (!s) return 128000; + const match = s.match(/^([\d.]+)([KMG])?$/i); + if (!match) return 128000; + const num = parseFloat(match[1]!); + const unit = (match[2] ?? '').toUpperCase(); + if (unit === 'K') return Math.round(num * 1000); + if (unit === 'M') return Math.round(num * 1000000); + if (unit === 'G') return Math.round(num * 1000000000); + return Math.round(num); +}; + +export async function listPiModels(envKeys: Record): Promise { + if (cachedModels && Date.now() - cacheTimestamp < CACHE_TTL_MS) { + return cachedModels; + } + + try { + const proc = Bun.spawn(['pi', '--list-models'], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, ...envKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (proc.exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderr.trim() }); + return []; + } + + const lines = output.trim().split('\n'); + if (lines.length < 2) return []; + + // Parse fixed-width table: provider, model, context, max-out, thinking, images + const header = lines[0]!; + const colStarts = [ + header.indexOf('provider'), + header.indexOf('model'), + header.indexOf('context'), + header.indexOf('max-out'), + header.indexOf('thinking'), + header.indexOf('images'), + ]; + + const extractCol = (line: string, colIdx: number): string => { + const start = colStarts[colIdx]!; + const end = colIdx < colStarts.length - 1 ? colStarts[colIdx + 1]! : line.length; + return line.slice(start, end).trim(); + }; + + const models: ModelInfo[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]!; + if (!line.trim()) continue; + + const provider = extractCol(line, 0); + const model = extractCol(line, 1); + const context = extractCol(line, 2); + const maxOut = extractCol(line, 3); + const thinking = extractCol(line, 4); + const images = extractCol(line, 5); + + models.push({ + id: `${provider}/${model}`, + name: model, + provider, + contextWindow: parseSize(context), + maxTokens: parseSize(maxOut), + reasoning: thinking === 'yes', + images: images === 'yes', + }); + } + + logger.info('pi --list-models returned', { count: models.length }); + cachedModels = models; + cacheTimestamp = Date.now(); + return models; + } catch (err) { + logger.error('Failed to run pi --list-models', { error: String(err) }); + return []; + } +} diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 99f743d4..c6e26d29 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -1,6 +1,7 @@ import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readApiKeys } from "../server-settings/pi-mono"; +import { PI_CONFIG_DIR } from "../../data-path"; import { logger } from "./logger"; export type PiEventHandler = (event: PiEvent) => void; @@ -20,7 +21,7 @@ export async function spawnPi( stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, ...storedKeys }, + env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, }); // Read stdout JSON event stream (runs in background) diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index c7d52090..403b27f3 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -2,9 +2,8 @@ import type { Context } from 'hono'; import { createRouter } from '../../create-router'; import * as storage from './storage'; import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono'; -import { getConfiguredProviders, fetchProviderModels } from '../server-settings/provider-registry'; +import { listPiModels } from './list-models'; import { getHomeDir } from '../../data-path'; -import type { ModelInfo } from './types'; import { logger } from './logger'; /** @@ -15,140 +14,25 @@ export const piRestRouter = createRouter(); /** * GET /api/pi/models - * List available models by running `pi --list-models` with stored API keys - * PLUS models from local providers (ollama, lmstudio, etc.) + * List available models via `pi --list-models` (with stored API keys + PI_CODING_AGENT_DIR). + * Local provider friendly names are resolved from stored local-providers config. */ piRestRouter.get('/pi/models', async (ctx: Context) => { - const models: ModelInfo[] = []; - const providerNames: Record = {}; - - // Helper to parse context window sizes - const parseSize = (s?: string): number => { - if (!s) return 128000; - const match = s.match(/^(\d+)([KMG])?$/i); - if (!match) return 128000; - const num = parseInt(match[1]!, 10); - const unit = (match[2] ?? '').toUpperCase(); - if (unit === 'K') return num * 1000; - if (unit === 'M') return num * 1000000; - if (unit === 'G') return num * 1000000000; - return num; - }; - try { - // 1. Fetch models from API-based providers (OpenAI, Anthropic, etc.) const storedKeys = await readApiKeys(); - const configuredProviders = getConfiguredProviders(storedKeys); - - logger.info('Fetching models from API providers', { count: configuredProviders.length }); - - for (const { provider, apiKey } of configuredProviders) { - try { - const providerModels = await fetchProviderModels(provider, apiKey); - logger.info('Fetched models from API provider', { - provider: provider.name, - count: providerModels.length - }); - - for (const m of providerModels) { - models.push({ - id: `${provider.id}/${m.id}`, - name: m.name, - provider: provider.id, - contextWindow: 128000, // Default - maxTokens: 4096, // Default - }); - } - } catch (err) { - logger.error('Failed to fetch models from API provider', { - provider: provider.name, - error: String(err), - }); - } - } + const models = await listPiModels(storedKeys); - // 2. Fetch models from local providers (ollama, lmstudio, openai-compatible) + // Build providerNames map for officer-local-* providers + const providerNames: Record = {}; const localProviders = await readLocalProviders(); - logger.info('Local providers loaded', { count: localProviders.length, providers: localProviders.map(p => p.name) }); - for (const lp of localProviders) { - 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 }); - continue; - } - - const data = await res.json(); - - // Use Pi's provider ID format: officer-local-{uuid} - const piProviderId = `officer-local-${lp.id}`; - - // Store friendly provider name for frontend display - providerNames[piProviderId] = lp.name; - - // Parse Ollama response - if (lp.apiType === 'ollama' && data.models) { - logger.info('Adding Ollama models', { provider: lp.name, count: data.models.length }); - for (const m of data.models) { - models.push({ - id: `${piProviderId}/${m.name}`, - name: m.name, - provider: piProviderId, - contextWindow: 128000, // Default - maxTokens: 4096, // Default - }); - } - } - // Parse OpenAI-compatible response (including LM Studio) - else if (data.data) { - logger.info('Adding OpenAI-compatible models', { provider: lp.name, count: data.data.length }); - for (const m of data.data) { - models.push({ - id: `${piProviderId}/${m.id}`, - name: m.id, - provider: piProviderId, - contextWindow: 128000, // Default - maxTokens: 4096, // Default - }); - } - } - } catch (err) { - logger.error('Failed to fetch models from local provider', { - provider: lp.name, - error: String(err) - }); - } + providerNames[`officer-local-${lp.id}`] = lp.name; } - logger.info('Returning models', { - totalModels: models.length, - providerCount: Object.keys(providerNames).length, - providers: models.reduce((acc, m) => { - acc[m.provider] = (acc[m.provider] || 0) + 1; - return acc; - }, {} as Record) - }); - return ctx.json({ models, providerNames }); } catch (err) { logger.error('Failed to list models', { error: String(err) }); - return ctx.json({ models, providerNames }); + return ctx.json({ models: [], providerNames: {} }); } }); diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index f4a30f01..831c22f7 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -149,4 +149,6 @@ export type ModelInfo = { provider: string; contextWindow: number; maxTokens: number; + reasoning?: boolean; + images?: boolean; }; diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts index 8c920287..349a3693 100644 --- a/src/servers/api/server-settings/pi-mono.ts +++ b/src/servers/api/server-settings/pi-mono.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { createRouter } from '../../create-router'; import { DATA_PATH } from '../../data-path'; import { syncLocalProvidersToPiConfig } from './sync-pi-config'; +import { invalidateModelCache } from '../pi/list-models'; export const piMonoRouter = createRouter(); @@ -214,6 +215,7 @@ piMonoRouter.put('/api-keys', async (ctx) => { delete keys[key]; } await writeApiKeys(keys); + invalidateModelCache(); return ctx.json({ ok: true }); }); diff --git a/src/servers/api/server-settings/provider-registry.ts b/src/servers/api/server-settings/provider-registry.ts deleted file mode 100644 index 40394ff9..00000000 --- a/src/servers/api/server-settings/provider-registry.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { logger } from '../pi/logger'; - -export type ApiProvider = { - id: string; - name: string; - apiType: 'openai-completions' | 'anthropic-messages' | 'google-generative-ai'; - baseUrl: string; - envKeys: string[]; - getApiKey: (keys: Record) => string | null; - hardcodedModels?: boolean; -}; - -/** - * Registry of API-based providers that Officer supports - * These will be synced to Pi's config and their models fetched - */ -export const API_PROVIDERS: ApiProvider[] = [ - { - id: 'openai', - name: 'OpenAI', - apiType: 'openai-completions', - baseUrl: 'https://api.openai.com/v1', - envKeys: ['OPENAI_API_KEY'], - getApiKey: (keys) => keys.OPENAI_API_KEY || null, - }, - { - id: 'anthropic', - name: 'Anthropic', - apiType: 'anthropic-messages', - baseUrl: 'https://api.anthropic.com/v1', - envKeys: ['ANTHROPIC_API_KEY'], - getApiKey: (keys) => keys.ANTHROPIC_API_KEY || null, - }, - { - id: 'opencode', - name: 'OpenCode Zen', - apiType: 'openai-completions', - baseUrl: 'https://api.opencodezen.com/v1', - envKeys: ['OPENCODE_API_KEY'], - getApiKey: (keys) => keys.OPENCODE_API_KEY || null, - hardcodedModels: true, // No public /models endpoint - }, - { - id: 'groq', - name: 'Groq', - apiType: 'openai-completions', - baseUrl: 'https://api.groq.com/openai/v1', - envKeys: ['GROQ_API_KEY'], - getApiKey: (keys) => keys.GROQ_API_KEY || null, - }, - { - id: 'mistral', - name: 'Mistral', - apiType: 'openai-completions', - baseUrl: 'https://api.mistral.ai/v1', - envKeys: ['MISTRAL_API_KEY'], - getApiKey: (keys) => keys.MISTRAL_API_KEY || null, - }, - { - id: 'xai', - name: 'xAI', - apiType: 'openai-completions', - baseUrl: 'https://api.x.ai/v1', - envKeys: ['XAI_API_KEY'], - getApiKey: (keys) => keys.XAI_API_KEY || null, - }, - { - id: 'openrouter', - name: 'OpenRouter', - apiType: 'openai-completions', - baseUrl: 'https://openrouter.ai/api/v1', - envKeys: ['OPENROUTER_API_KEY'], - getApiKey: (keys) => keys.OPENROUTER_API_KEY || null, - }, - { - id: 'google', - name: 'Google', - apiType: 'google-generative-ai', - baseUrl: 'https://generativelanguage.googleapis.com/v1beta', - envKeys: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'], - getApiKey: (keys) => keys.GOOGLE_API_KEY || keys.GEMINI_API_KEY || null, - }, -]; - -/** - * Fetch models from an OpenAI-compatible provider - */ -async function fetchOpenAIModels(baseUrl: string, apiKey: string): Promise<{ id: string; name: string }[]> { - try { - const res = await fetch(`${baseUrl}/models`, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(5000), - }); - - if (!res.ok) { - logger.error('Failed to fetch models from OpenAI-compatible API', { baseUrl, status: res.status }); - return []; - } - - const data = await res.json(); - if (!data.data || !Array.isArray(data.data)) return []; - - return data.data.map((m: any) => ({ - id: m.id, - name: m.id, - })); - } catch (err) { - logger.error('Failed to fetch OpenAI models', { baseUrl, error: String(err) }); - return []; - } -} - -/** - * Fetch models from Anthropic API - */ -async function fetchAnthropicModels(apiKey: string): Promise<{ id: string; name: string }[]> { - // Anthropic doesn't have a /models endpoint, so we return known models - // These are documented at https://docs.anthropic.com/en/docs/about-claude/models - return [ - { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' }, - { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, - { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' }, - { id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet' }, - { id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' }, - ]; -} - -/** - * Fetch models from Google Generative AI - */ -async function fetchGoogleModels(apiKey: string): Promise<{ id: string; name: string }[]> { - // Google also doesn't expose a simple models endpoint, return known models - return [ - { id: 'gemini-2.0-flash-exp', name: 'Gemini 2.0 Flash' }, - { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' }, - { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' }, - { id: 'gemini-1.5-flash-8b', name: 'Gemini 1.5 Flash 8B' }, - ]; -} - -/** - * Fetch models from OpenCode Zen - */ -async function fetchOpenCodeModels(apiKey: string): Promise<{ id: string; name: string }[]> { - // OpenCode Zen doesn't expose a public models endpoint, return known models - return [ - { id: 'big-pickle', name: 'Big Pickle' }, - { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }, - { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' }, - { id: 'claude-opus-4-5', name: 'Claude Opus 4.5' }, - { id: 'gemini-3-flash', name: 'Gemini 3 Flash' }, - ]; -} - -/** - * Fetch models from a configured API provider - */ -export async function fetchProviderModels( - provider: ApiProvider, - apiKey: string, -): Promise<{ id: string; name: string }[]> { - // Handle providers with hardcoded models first - if (provider.id === 'opencode') { - return fetchOpenCodeModels(apiKey); - } - - switch (provider.apiType) { - case 'openai-completions': - return fetchOpenAIModels(provider.baseUrl, apiKey); - case 'anthropic-messages': - return fetchAnthropicModels(apiKey); - case 'google-generative-ai': - return fetchGoogleModels(apiKey); - default: - logger.warn('Unknown API type for provider', { provider: provider.id, apiType: provider.apiType }); - return []; - } -} - -/** - * Get all configured API providers with their API keys - */ -export function getConfiguredProviders(storedKeys: Record): Array<{ provider: ApiProvider; apiKey: string }> { - const configured: Array<{ provider: ApiProvider; apiKey: string }> = []; - - for (const provider of API_PROVIDERS) { - const apiKey = provider.getApiKey(storedKeys); - if (apiKey) { - configured.push({ provider, apiKey }); - } - } - - return configured; -} diff --git a/src/servers/api/server-settings/sync-pi-config.ts b/src/servers/api/server-settings/sync-pi-config.ts index 99628893..d4f4d81b 100644 --- a/src/servers/api/server-settings/sync-pi-config.ts +++ b/src/servers/api/server-settings/sync-pi-config.ts @@ -1,11 +1,10 @@ import { join } from 'node:path'; -import { homedir } from 'node:os'; import type { LocalProvider } from './pi-mono'; -import { readLocalProviders, readApiKeys } from './pi-mono'; -import { getConfiguredProviders, fetchProviderModels } from './provider-registry'; +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_CONFIG_DIR = join(homedir(), '.pi', 'agent'); const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json'); type PiModelConfig = { @@ -31,107 +30,48 @@ type PiModelConfig = { }; /** - * Sync Officer's providers (both API-based and local) to Pi's models.json config - * This makes Pi aware of all providers configured in Officer + * 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 { - // Read existing Pi config const piModelsFile = Bun.file(PI_MODELS_FILE); let piConfig: PiModelConfig; - + if (await piModelsFile.exists()) { piConfig = await piModelsFile.json(); } else { piConfig = { providers: {} }; } - // Remove ALL Officer-managed provider entries (cleanup) + // Remove all officer-local-* entries (will be re-added below) for (const providerId of Object.keys(piConfig.providers)) { - if (providerId.startsWith('officer-local-') || - ['openai', 'anthropic', 'opencode', 'groq', 'mistral', 'xai', 'openrouter', 'google'].includes(providerId)) { + if (providerId.startsWith('officer-local-')) { delete piConfig.providers[providerId]; } } - // 1. Sync API-based providers - const storedKeys = await readApiKeys(); - const configuredProviders = getConfiguredProviders(storedKeys); - - logger.info('Syncing API-based providers to Pi config', { count: configuredProviders.length }); - - for (const { provider, apiKey } of configuredProviders) { - try { - const providerModels = await fetchProviderModels(provider, apiKey); - - if (providerModels.length === 0) { - logger.warn('No models found for API provider', { provider: provider.name }); - continue; - } - - piConfig.providers[provider.id] = { - baseUrl: provider.baseUrl, - apiKey, - api: provider.apiType, - models: providerModels.map(m => ({ - id: m.id, - name: m.name, - reasoning: false, - input: ['text'], - contextWindow: 128000, - maxTokens: 4096, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - })), - }; - - logger.info('Added API provider to Pi config', { - providerId: provider.id, - providerName: provider.name, - modelCount: providerModels.length - }); - } catch (err) { - logger.error('Failed to sync API provider', { - provider: provider.name, - error: String(err) - }); - } - } - - // 2. Sync local providers + // Add local providers const localProviders = await readLocalProviders(); logger.info('Syncing local providers to Pi config', { count: localProviders.length }); - // Add local providers to Pi config for (const lp of localProviders) { const providerId = `officer-local-${lp.id}`; - - // Fetch models from the local provider const models = await fetchModelsFromLocalProvider(lp); - + if (models.length === 0) { logger.warn('No models found for local provider', { provider: lp.name }); continue; } - // All local providers use OpenAI-compatible API - // (llama.cpp, Ollama with /v1 endpoint, LM Studio, etc.) - const apiType = 'openai-completions'; - - // llama.cpp and OpenAI-compatible servers expect /v1 prefix - // Ollama also supports OpenAI-compatible at /v1 - const baseUrl = lp.apiType === 'ollama' + 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: apiType, + api: 'openai-completions', models: models.map(m => ({ id: m.id, name: m.name || m.id, @@ -139,24 +79,15 @@ export async function syncLocalProvidersToPiConfig(): Promise { input: ['text'], contextWindow: m.contextWindow || 128000, maxTokens: m.maxTokens || 4096, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, })), }; - logger.info('Added local provider to Pi config', { - providerId, - providerName: lp.name, - modelCount: models.length - }); + logger.info('Added local provider to Pi config', { providerId, providerName: lp.name, modelCount: models.length }); } - // Write updated config back to Pi 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) }); @@ -164,12 +95,12 @@ export async function syncLocalProvidersToPiConfig(): Promise { } async function fetchModelsFromLocalProvider( - lp: LocalProvider + 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}`; @@ -184,40 +115,21 @@ async function fetchModelsFromLocalProvider( clearTimeout(timer); if (!res.ok) { - logger.error('Failed to fetch models from local provider', { - provider: lp.name, - status: res.status - }); + logger.error('Failed to fetch models from local provider', { provider: lp.name, status: res.status }); return []; } const data = await res.json(); - // Parse Ollama response if (lp.apiType === 'ollama' && data.models) { - return data.models.map((m: any) => ({ - id: m.name, - name: m.name, - contextWindow: 128000, - maxTokens: 4096, - })); - } - // Parse OpenAI-compatible response (including LM Studio) - else if (data.data) { - return data.data.map((m: any) => ({ - id: m.id, - name: m.id, - contextWindow: 128000, - maxTokens: 4096, - })); + 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) - }); + logger.error('Failed to fetch models from local provider', { provider: lp.name, error: String(err) }); return []; } } diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 33d43e65..52deda7e 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -1,10 +1,72 @@ -import { mkdirSync } from 'node:fs'; -import { DATA_PATH } from './data-path'; +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'; mkdirSync(DATA_PATH, { recursive: true }); +mkdirSync(PI_CONFIG_DIR, { recursive: true }); -// Sync local providers to Pi config on startup -syncLocalProvidersToPiConfig().catch(err => { - console.error('Failed to sync local providers to Pi config on startup:', err); -}); +async function ensurePiInstalled(): Promise { + try { + const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + return proc.exitCode === 0; + } catch { + return false; + } +} + +async function installPi(): Promise { + console.log('[bootstrap] Pi not found, installing...'); + try { + const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], { + stdout: 'pipe', + stderr: 'pipe', + }); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + if (proc.exitCode !== 0) { + console.error('[bootstrap] Pi installation failed:', stderr.trim()); + return false; + } + console.log('[bootstrap] Pi installed successfully'); + return true; + } catch (err) { + console.error('[bootstrap] Pi installation error:', err); + return false; + } +} + +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) { + const ok = await installPi(); + if (!ok) { + console.error('[bootstrap] Could not install Pi — model discovery will not work'); + return; + } + } + + seedPiConfig(); + + await syncLocalProvidersToPiConfig().catch(err => { + console.error('[bootstrap] Failed to sync local providers to Pi config:', err); + }); +})(); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 3a90941a..1533cab4 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -2,6 +2,8 @@ import { join, resolve } from 'node:path'; export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +export const PI_CONFIG_DIR = join(DATA_PATH, 'pi-config'); + export const SEED_PATH = resolve(import.meta.dir, '../../seed'); export const getUserSessionsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions'); diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index 10f791f7..7ab6489b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -14,6 +14,8 @@ export type ModelOption = { provider: string; contextWindow: number; maxTokens: number; + reasoning?: boolean; + images?: boolean; }; export type SessionEntry = { diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 8b88055a..6a540c30 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -95,8 +95,8 @@ export const SessionList = () => { {(() => { if (!session.model.includes('/')) return session.model; - const [provider, modelId] = session.model.split('/'); - + const [provider, modelId] = session.model.split('/') as [string, string]; + // Handle local providers - show friendly name if (provider.startsWith('officer-local-')) { const friendlyName = getProviderDisplayName(provider); diff --git a/src/workspaces/state/src/useModels.ts b/src/workspaces/state/src/useModels.ts index f1be549c..ceb92301 100644 --- a/src/workspaces/state/src/useModels.ts +++ b/src/workspaces/state/src/useModels.ts @@ -30,7 +30,7 @@ export function usePiModels() { console.log('[usePiModels] Fetched models:', { modelCount: data.models.length, providerNames: data.providerNames, - providers: [...new Set(data.models.map(m => m.provider))] + providers: [...new Set(data.models.map((m: ModelOption) => m.provider))] }); // Store provider names for later use @@ -51,34 +51,29 @@ export function useVisiblePiModels() { const models = usePiModels(); const { settings } = useSettings(); const enabled = settings.ai?.enabledModels ?? []; + const disabledProviders = new Set(settings.ai?.disabledProviders ?? []); - // If no enabled models list exists, show all models + // First filter out disabled providers + const providerFiltered = disabledProviders.size > 0 + ? models.filter((m) => !disabledProviders.has(m.provider)) + : models; + + // If no enabled models list, show all (after provider filtering) if (enabled.length === 0) { - console.log('[useVisiblePiModels] No enabled models list, showing all'); - return models; + return providerFiltered; } // Get all providers from enabled models - const enabledProviders = new Set( - enabled.map(key => key.split(':')[0]) + const enabledProviderSet = new Set( + enabled.map(key => key.split(':')[0]), ); // Filter to include: // 1. Models that are explicitly enabled // 2. Models from providers that aren't in the enabled list at all (new providers) - const filtered = models.filter((m) => { + return providerFiltered.filter((m) => { const isExplicitlyEnabled = enabled.includes(modelKey(m)); - const isFromNewProvider = !enabledProviders.has(m.provider); + const isFromNewProvider = !enabledProviderSet.has(m.provider); return isExplicitlyEnabled || isFromNewProvider; }); - - console.log('[useVisiblePiModels]', { - allModels: models.length, - enabledModels: enabled, - enabledProviders: Array.from(enabledProviders), - filtered: filtered.length, - finalCount: filtered.length - }); - - return filtered; } diff --git a/src/workspaces/state/src/useSettings.ts b/src/workspaces/state/src/useSettings.ts index f0dca27d..840c23ce 100644 --- a/src/workspaces/state/src/useSettings.ts +++ b/src/workspaces/state/src/useSettings.ts @@ -10,6 +10,7 @@ const mergeWithDefaults = (saved: Partial): UserSettings => ({ ai: { enabledModels: saved.ai?.enabledModels?.length ? saved.ai.enabledModels : DEFAULT_SETTINGS.ai.enabledModels, enabledProviders: saved.ai?.enabledProviders ?? DEFAULT_SETTINGS.ai.enabledProviders, + disabledProviders: saved.ai?.disabledProviders ?? DEFAULT_SETTINGS.ai.disabledProviders, }, tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks }, appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance }, @@ -55,6 +56,7 @@ export type UserSettings = { ai: { enabledModels: string[]; enabledProviders: string[]; + disabledProviders: string[]; }; tasks: { defaultProvider: 'pi'; @@ -84,6 +86,7 @@ export const DEFAULT_SETTINGS: UserSettings = { ai: { enabledModels: [], enabledProviders: [], + disabledProviders: [], }, tasks: { defaultProvider: 'pi',