Local models

This commit is contained in:
2026-02-22 18:04:01 +00:00
parent e35b7340f3
commit 1372f53782
8 changed files with 640 additions and 58 deletions
+127 -51
View File
@@ -1,7 +1,8 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
import { readApiKeys } from '../server-settings/pi-mono';
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
import { getConfiguredProviders, fetchProviderModels } from '../server-settings/provider-registry';
import { getHomeDir } from '../../data-path';
import type { ModelInfo } from './types';
import { logger } from './logger';
@@ -15,64 +16,139 @@ 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.)
*/
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);
const proc = Bun.spawn(['pi', '--list-models'], {
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...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),
});
}
}
// 2. Fetch models from local providers (ollama, lmstudio, openai-compatible)
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)
});
}
}
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>)
});
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) {
logger.error('pi --list-models failed', { exitCode: proc.exitCode });
return ctx.json({ models: [] });
}
// Parse the whitespace-separated table output:
// provider model context max-out thinking images
// anthropic claude-sonnet-4-6 200K 128K yes yes
const lines = output.trim().split('\n').filter(Boolean);
const models: ModelInfo[] = [];
// Skip header line (first line)
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.trim().split(/\s+/);
if (cols.length < 2) continue;
const [provider, model, context, maxOut] = cols;
// Parse context window (e.g., "200K" -> 200000)
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;
};
models.push({
id: `${provider}/${model}`,
name: model!,
provider: provider!,
contextWindow: parseSize(context),
maxTokens: parseSize(maxOut),
});
}
return ctx.json({ models });
return ctx.json({ models, providerNames });
} catch (err) {
logger.error('Failed to list models', { error: String(err) });
return ctx.json({ models: [] });
return ctx.json({ models, providerNames });
}
});