Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 80 additions & 26 deletions src/cli/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ async function handlePluginSetup(
selectedModel = preselectedModel;
logger.success(`Model selected automatically: ${selectedModel}`);
} else {
selectedModel = await promptForModelSelection(models, providerTemplate);
selectedModel = await promptForModelSelection(models, providerTemplate, setupSteps, credentials);
}

// Step 3.5: Install model if provider supports it (e.g., Ollama)
Expand Down Expand Up @@ -446,50 +446,104 @@ async function promptForProfileName(providerName: string): Promise<string> {
*/
async function promptForModelSelection(
models: string[],
providerTemplate?: any
providerTemplate?: any,
setupSteps?: any,
credentials?: any
): Promise<string> {
const canSearch = typeof setupSteps?.searchModel === 'function';

if (models.length === 0) {
if (canSearch) {
const { entryMethod } = await inquirer.prompt([
{
type: 'list',
name: 'entryMethod',
message: 'No models found. How would you like to choose a model?',
choices: [
{ name: chalk.cyan('🔍 Search Ollama library...'), value: 'search' },
{ name: 'Enter model name manually', value: 'manual' }
]
}
]);

if (entryMethod === 'search') {
const searched = await setupSteps.searchModel(credentials);
if (searched) {
return searched;
}
// User backed out of search - fall through to manual entry
}
}

const { manualModel } = await inquirer.prompt([
{
type: 'input',
name: 'manualModel',
message: 'No models found. Enter model name manually:',
message: 'Enter model name manually:',
default: providerTemplate?.recommendedModels?.[0] || 'gpt-5.5',
validate: (input: string) => input.trim() !== '' || 'Model name is required'
}
]);
return manualModel ? manualModel.trim() : manualModel;
}

// Use getAllModelChoices for enriched display with metadata
const choices = [
...getAllModelChoices(models, providerTemplate),
{ name: chalk.white('Custom model (manual entry)...'), value: 'custom' }
];

const { selectedModel } = await inquirer.prompt([
{
type: 'list',
name: 'selectedModel',
message: `Choose a model (${models.length} available):`,
choices,
pageSize: 15
// Live-computed recommendations (fits this machine + agent-ready + most
// popular) when the provider supports it; falls back to the template's
// static recommendedModels inside getAllModelChoices otherwise.
let recommendedOverrideIds: Set<string> | undefined;
if (typeof setupSteps?.getRecommendedModels === 'function') {
const recommendSpinner = ora('Finding recommended models...').start();
try {
const recommended = await setupSteps.getRecommendedModels(models, credentials);
recommendedOverrideIds = new Set(recommended);
recommendSpinner.stop();
} catch {
recommendSpinner.stop();
// Non-fatal - just show the list without recommendations.
}
]);
}

// Loop so backing out of search re-shows this list instead of dead-ending
for (;;) {
// Use getAllModelChoices for enriched display with metadata
const choices = [
...getAllModelChoices(models, providerTemplate, recommendedOverrideIds),
{ name: chalk.white('Custom model (manual entry)...'), value: 'custom' },
...(canSearch ? [{ name: chalk.cyan('🔍 Search Ollama library...'), value: 'search' }] : [])
];

if (selectedModel === 'custom') {
const { customModel } = await inquirer.prompt([
const { selectedModel } = await inquirer.prompt([
{
type: 'input',
name: 'customModel',
message: 'Enter model name:',
validate: (input: string) => input.trim() !== '' || 'Model is required'
type: 'list',
name: 'selectedModel',
message: `Choose a model (${models.length} available):`,
choices,
pageSize: 15
}
]);
return customModel ? customModel.trim() : customModel;
}

return selectedModel;
if (selectedModel === 'search') {
const searched = await setupSteps.searchModel(credentials);
if (searched) {
return searched;
}
continue;
}

if (selectedModel === 'custom') {
const { customModel } = await inquirer.prompt([
{
type: 'input',
name: 'customModel',
message: 'Enter model name:',
validate: (input: string) => input.trim() !== '' || 'Model is required'
}
]);
return customModel ? customModel.trim() : customModel;
}

return selectedModel;
}
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/providers/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,23 @@ export interface ProviderSetupSteps {
template?: ProviderTemplate
): Promise<string | null | undefined>;

/**
* Optional: interactive live search against an external model catalog
* (e.g. Ollama's model library). Runs its own prompts and returns the
* chosen model id, or null if the user cancelled/backed out - the caller
* falls back to the normal model list in that case.
*/
searchModel?(credentials: ProviderCredentials): Promise<string | null>;

/**
* Optional: compute which of the given models should be marked/starred
* as recommended, using live signals (fits the current machine, has the
* capabilities a coding agent needs, real-world popularity) instead of a
* static hardcoded list. Returning fewer/no ids is fine - callers treat
* this as "no recommendation" rather than an error.
*/
getRecommendedModels?(models: string[], credentials: ProviderCredentials): Promise<string[]>;

/**
* Step 3: Build final configuration
*
Expand Down
11 changes: 7 additions & 4 deletions src/providers/integration/setup-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,21 @@ export function formatModelChoice(
* Get all model choices with metadata
*
* Returns array of formatted model choices, sorted by:
* 1. Recommended models first — only the latest version within each
* recommendedModels family (see computeRecommendedModelIds)
* 1. Recommended models first — either `recommendedOverrideIds` (a live,
* provider-computed set - see ProviderSetupSteps.getRecommendedModels)
* when given, or only the latest version within each of the template's
* static recommendedModels families otherwise (see computeRecommendedModelIds)
* 2. Alphabetically by model ID
*
* Choices whose declared memory requirement (modelMetadata.minMemoryGb)
* exceeds the current system are included but disabled with an explanation.
*/
export function getAllModelChoices(
models: string[],
template?: ProviderTemplate
template?: ProviderTemplate,
recommendedOverrideIds?: Set<string>
): Array<{ name: string; value: string; disabled?: boolean | string }> {
const recommendedIds = computeRecommendedModelIds(models, template?.recommendedModels);
const recommendedIds = recommendedOverrideIds ?? computeRecommendedModelIds(models, template?.recommendedModels);

// Sort models using common rules
const sortedModels = [...models].sort((a, b) => {
Expand Down
139 changes: 139 additions & 0 deletions src/providers/plugins/ollama/ollama.library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Ollama Library Search
*
* Ollama has no public JSON API for its model library - only the
* server-rendered HTML pages at ollama.com/search and
* ollama.com/library/<model>/tags. This scrapes those pages so the setup
* wizard can offer live search instead of the narrow static/cloud-catalog
* list in ollama.models.ts. Unofficial by nature: any parse failure must be
* treated as non-fatal by callers (they fall back to manual entry).
*/

import { HTTPClient } from '../../core/base/http-client.js';

const OLLAMA_WEB_BASE_URL = 'https://ollama.com';
const MAX_SEARCH_RESULTS = 15;

const client = new HTTPClient({ timeout: 10000 });

export interface OllamaLibraryModel {
name: string;
description?: string;
}

function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}

/**
* Search Ollama's model library (ollama.com/search).
* Returns base model names (no tag) in the page's own order (popular first).
*/
export async function searchOllamaLibrary(query: string): Promise<OllamaLibraryModel[]> {
const url = `${OLLAMA_WEB_BASE_URL}/search?q=${encodeURIComponent(query)}`;
const response = await client.getRaw(url);

if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Ollama library search failed: HTTP ${response.statusCode}`);
}

const results: OllamaLibraryModel[] = [];
const seen = new Set<string>();

// Each result is an <li> card containing a `/library/<slug>` link followed
// by an <h2> title and a <p> description. Base model links never contain
// ":" (tag links do), so filtering on that distinguishes result cards from
// any tag references elsewhere on the page.
const cardPattern = /href="\/library\/([a-zA-Z0-9._-]+)"[\s\S]{0,600}?<\/h2>\s*<p[^>]*>([\s\S]*?)<\/p>/g;
let match: RegExpExecArray | null;

while ((match = cardPattern.exec(response.data)) && results.length < MAX_SEARCH_RESULTS) {
const [, slug, rawDescription] = match;
if (slug.includes(':') || seen.has(slug)) {
continue;
}
seen.add(slug);

const description = decodeHtmlEntities(rawDescription.replace(/<[^>]+>/g, ''));
results.push({ name: slug, description: description || undefined });
}

if (results.length === 0) {
throw new Error(`No parsable results for "${query}" - ollama.com's page structure may have changed`);
}

return results;
}

/**
* List the installable tags/variants for a model (ollama.com/library/<model>/tags).
* ":latest" (or the bare model id when no ":latest" tag exists) is returned first.
*/
export async function listOllamaModelTags(modelSlug: string): Promise<string[]> {
const url = `${OLLAMA_WEB_BASE_URL}/library/${encodeURIComponent(modelSlug)}/tags`;
const response = await client.getRaw(url);

if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Failed to fetch tags for "${modelSlug}": HTTP ${response.statusCode}`);
}

const escapedSlug = modelSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const tagPattern = new RegExp(`href="/library/(${escapedSlug}:[a-zA-Z0-9._-]+)"`, 'g');
const tags = new Set<string>();
let match: RegExpExecArray | null;

while ((match = tagPattern.exec(response.data))) {
tags.add(match[1]);
}

if (tags.size === 0) {
throw new Error(`No tags found for "${modelSlug}" - ollama.com's page structure may have changed`);
}

const latestTag = `${modelSlug}:latest`;
const rest = [...tags].filter(tag => tag !== latestTag).sort();
return tags.has(latestTag) ? [latestTag, ...rest] : rest;
}

export interface OllamaModelDetails {
/** Approximate pull/download count parsed from the model page (0 if unknown). */
downloads: number;
/** Whether the model's page advertises tool/function-calling support. */
supportsTools: boolean;
}

function parseCountSuffix(raw: string): number {
const match = raw.replace(/,/g, '').match(/^([\d.]+)\s*([KMB])?$/i);
if (!match) {
return 0;
}
const value = parseFloat(match[1]);
const multiplier = { K: 1e3, M: 1e6, B: 1e9 }[match[2]?.toUpperCase() as 'K' | 'M' | 'B'] ?? 1;
return Math.round(value * multiplier);
}

/**
* Fetch a model's download count and tool-support badge from its
* ollama.com/library/<model> page - used to rank live recommendations
* (popularity + agentic capability) instead of a hardcoded list.
*/
export async function getOllamaModelDetails(modelSlug: string): Promise<OllamaModelDetails> {
const url = `${OLLAMA_WEB_BASE_URL}/library/${encodeURIComponent(modelSlug)}`;
const response = await client.getRaw(url);

if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Failed to fetch model page for "${modelSlug}": HTTP ${response.statusCode}`);
}

const downloadsMatch = response.data.match(/<span\s*>([\d.,]+[KMB]?)<\/span>\s*<span[^>]*>&nbsp;Downloads<\/span>/i);
const downloads = downloadsMatch ? parseCountSuffix(downloadsMatch[1]) : 0;
const supportsTools = /class="[^"]*"\s*>\s*tools\s*<\/span>/i.test(response.data);

return { downloads, supportsTools };
}
Loading
Loading