new flow for Pi provider/model settings
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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<string, string>): Promise<ModelInfo[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+8
-124
@@ -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<string, string> = {};
|
||||
|
||||
// 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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
if (lp.auth?.type === 'api-key') {
|
||||
headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
|
||||
} else if (lp.auth?.type === 'basic') {
|
||||
headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!res.ok) {
|
||||
logger.error('Failed to fetch models from local provider', { provider: lp.name, status: res.status });
|
||||
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<string, number>)
|
||||
});
|
||||
|
||||
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: {} });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -149,4 +149,6 @@ export type ModelInfo = {
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
reasoning?: boolean;
|
||||
images?: boolean;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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, string>) => 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<string, string>): 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
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<string, string> = {};
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ModelOption = {
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
reasoning?: boolean;
|
||||
images?: boolean;
|
||||
};
|
||||
|
||||
export type SessionEntry = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): 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',
|
||||
|
||||
Reference in New Issue
Block a user