new flow for Pi provider/model settings
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user