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 });
}
});
@@ -1,6 +1,7 @@
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '../../data-path';
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
export const piMonoRouter = createRouter();
@@ -298,6 +299,10 @@ piMonoRouter.post('/local-providers', async (ctx) => {
providers.push(provider);
await writeLocalProviders(providers);
// Sync to Pi config so Pi knows about this provider
await syncLocalProvidersToPiConfig();
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
});
@@ -307,6 +312,10 @@ piMonoRouter.delete('/local-providers/:id', async (ctx) => {
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();
return ctx.json({ ok: true });
});
@@ -0,0 +1,194 @@
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;
}
@@ -0,0 +1,223 @@
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 { logger } from '../pi/logger';
const PI_CONFIG_DIR = join(homedir(), '.pi', 'agent');
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
type PiModelConfig = {
providers: Record<string, {
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}[];
}>;
};
/**
* 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
*/
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)
for (const providerId of Object.keys(piConfig.providers)) {
if (providerId.startsWith('officer-local-') ||
['openai', 'anthropic', 'opencode', 'groq', 'mistral', 'xai', 'openrouter', 'google'].includes(providerId)) {
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
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'
? `${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,
models: models.map(m => ({
id: m.id,
name: m.name || m.id,
reasoning: false,
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
});
}
// Write updated config back to Pi
await Bun.write(PI_MODELS_FILE, JSON.stringify(piConfig, null, 2));
logger.info('Successfully synced local providers to Pi config');
} catch (err) {
logger.error('Failed to sync local providers to Pi config', { error: String(err) });
}
}
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<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
});
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 [];
} catch (err) {
logger.error('Failed to fetch models from local provider', {
provider: lp.name,
error: String(err)
});
return [];
}
}
+6
View File
@@ -1,4 +1,10 @@
import { mkdirSync } from 'node:fs';
import { DATA_PATH } from './data-path';
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
mkdirSync(DATA_PATH, { 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);
});
@@ -1,5 +1,6 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from 'state/useModels';
import { getProviderDisplayName } from 'state/useModels';
import type { ChatMessage } from '../types';
const PROVIDER_DISPLAY: Record<string, string> = {
@@ -41,6 +42,12 @@ export function ModelSelector({
hasStarted,
}: ModelSelectorProps) {
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
console.log('[ModelSelector]', {
availableModelsCount: availableModels.length,
providers,
models: availableModels.map(m => ({ id: m.id, provider: m.provider, name: m.name }))
});
// Determine which model to display: selectedModel takes precedence, then model (from server), then fallback
const displayModel = selectedModel || model;
@@ -58,7 +65,15 @@ export function ModelSelector({
if (firstModel) onModelChange(firstModel.id);
};
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
const displayName = (provider: string) => {
// Handle local providers (officer-local-{uuid})
if (provider.startsWith('officer-local-')) {
const name = getProviderDisplayName(provider);
console.log('[displayName] Local provider:', provider, '→', name);
return name;
}
return PROVIDER_DISPLAY[provider] ?? provider;
};
// Get display text for the model
const getModelDisplayText = () => {
@@ -3,6 +3,7 @@ import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-re
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { useChatGroups } from 'state/useChatGroups';
import { getProviderDisplayName } from 'state/useModels';
import type { SelectedSession } from './ChatDetailPanel';
import { CreateGroupDialog } from './CreateGroupDialog';
import { GroupContextMenu } from './GroupContextMenu';
@@ -91,7 +92,20 @@ export const SessionList = () => {
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
{(() => {
if (!session.model.includes('/')) return session.model;
const [provider, modelId] = session.model.split('/');
// Handle local providers - show friendly name
if (provider.startsWith('officer-local-')) {
const friendlyName = getProviderDisplayName(provider);
return `${friendlyName} - ${modelId}`;
}
// Regular providers - just replace / with -
return session.model.replace('/', ' - ');
})()}
</div>
)}
</div>
+50 -5
View File
@@ -10,6 +10,13 @@ export function modelKey(m: ModelOption): string {
return `${m.provider}:${m.id}`;
}
// Store provider names globally for display
let globalProviderNames: Record<string, string> = {};
export function getProviderDisplayName(providerId: string): string {
return globalProviderNames[providerId] || providerId;
}
export function usePiModels() {
const client = useClient();
const { isAuthenticated } = useAuth();
@@ -18,7 +25,20 @@ export function usePiModels() {
queryKey: ['PI_MODELS'],
enabled: isAuthenticated,
queryFn: async () => {
const data = await client.get<{ models: ModelOption[] }>('/pi/models');
const data = await client.get<{ models: ModelOption[]; providerNames?: Record<string, string> }>('/pi/models');
console.log('[usePiModels] Fetched models:', {
modelCount: data.models.length,
providerNames: data.providerNames,
providers: [...new Set(data.models.map(m => m.provider))]
});
// Store provider names for later use
if (data.providerNames) {
globalProviderNames = data.providerNames;
console.log('[usePiModels] Stored provider names:', globalProviderNames);
}
return data.models;
},
staleTime: 5 * 60 * 1000,
@@ -32,8 +52,33 @@ export function useVisiblePiModels() {
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
// If no models match the visibility filter, show all
// The provider list changes dynamically based on API keys so the filter may be stale
return filtered.length > 0 ? filtered : models;
// If no enabled models list exists, show all models
if (enabled.length === 0) {
console.log('[useVisiblePiModels] No enabled models list, showing all');
return models;
}
// Get all providers from enabled models
const enabledProviders = 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) => {
const isExplicitlyEnabled = enabled.includes(modelKey(m));
const isFromNewProvider = !enabledProviders.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;
}