Skip to content

Commit ebb3457

Browse files
committed
feat: unified dynamic model fetching for all providers
Add a single `models/fetch` protocol message that dynamically fetches available models for any provider: - Ollama: scrapes the library page server-side (no CORS issues), with provider-specific icons and popular/other categorization - OpenRouter: fetches from their public API - Anthropic: fetches from /v1/models with display names - Gemini: fetches from /v1beta/models, filtering out 2.0, gemma, and non-chat models (embeddings, image/video gen, TTS, robotics) - All other providers: uses the LLM class's `listModels()` method Fetched models include contextLength, maxTokens (completionOptions), and tool use capability where available from the provider APIs. The Add Model form auto-fetches Ollama and OpenRouter models on mount. For other providers, a refresh icon appears next to the Model label once an API key is entered, allowing users to fetch available models. Also adds Gemma 4 model support and updates vision/tool detection for it.
1 parent d971363 commit ebb3457

17 files changed

Lines changed: 601 additions & 132 deletions

File tree

core/core.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CodebaseIndexer } from "./indexing/CodebaseIndexer";
1717
import DocsService from "./indexing/docs/DocsService";
1818
import { countTokens } from "./llm/countTokens";
1919
import Lemonade from "./llm/llms/Lemonade";
20+
import { fetchModels } from "./llm/fetchModels";
2021
import Ollama from "./llm/llms/Ollama";
2122
import { EditAggregator } from "./nextEdit/context/aggregateEdits";
2223
import { createNewPromptFileV2 } from "./promptFiles/createNewPromptFile";
@@ -1227,6 +1228,19 @@ export class Core {
12271228
const isValid = setMdmLicenseKey(licenseKey);
12281229
return isValid;
12291230
});
1231+
1232+
on("models/fetch", async (msg) => {
1233+
try {
1234+
return await fetchModels(
1235+
msg.data.provider,
1236+
msg.data.apiKey,
1237+
msg.data.apiBase,
1238+
);
1239+
} catch (error: any) {
1240+
void this.ide.showToast("error", error.message);
1241+
return [];
1242+
}
1243+
});
12301244
}
12311245

12321246
private async handleToolCall(toolCall: ToolCall) {

core/llm/autodetect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ const MODEL_SUPPORTS_IMAGES: RegExp[] = [
152152
/pixtral/,
153153
/llama-?3\.2/,
154154
/llama-?4/, // might use something like /llama-?(?:[4-9](?:\.\d+)?|\d{2,}(?:\.\d+)?)/ for forward compat, if needed
155-
/\bgemma-?3(?!n)/, // gemma3 supports vision, but gemma3n doesn't!
155+
/\bgemma-?[34](?!n)/, // gemma3/gemma4 support vision, but gemma3n doesn't!
156156
/\b(pali|med)gemma/,
157157
/qwen(.*)vl/,
158158
/mistral-small/,

core/llm/fetchModels.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { LLMClasses, llmFromProviderAndOptions } from "./llms/index.js";
2+
3+
export interface FetchedModel {
4+
name: string;
5+
modelId?: string;
6+
description?: string;
7+
icon?: string;
8+
popular?: boolean;
9+
contextLength?: number;
10+
maxTokens?: number;
11+
supportsTools?: boolean;
12+
}
13+
14+
const OLLAMA_EXCLUDED_CAPABILITIES = ["vision", "audio", "embedding"];
15+
16+
const OLLAMA_POPULAR_PREFIXES = [
17+
"gemma",
18+
"kimi",
19+
"glm",
20+
"deepseek",
21+
"llama",
22+
"qwen",
23+
"mistral",
24+
"devstral",
25+
];
26+
27+
const OLLAMA_ICON_MAP: Record<string, string> = {
28+
llama: "meta.png",
29+
codellama: "meta.png",
30+
"phind-codellama": "meta.png",
31+
deepseek: "deepseek.png",
32+
deepcoder: "deepseek.png",
33+
deepscaler: "deepseek.png",
34+
mistral: "mistral.png",
35+
mixtral: "mistral.png",
36+
codestral: "mistral.png",
37+
devstral: "mistral.png",
38+
magistral: "mistral.png",
39+
mathstral: "mistral.png",
40+
ministral: "mistral.png",
41+
gemma: "gemini.png",
42+
codegemma: "gemini.png",
43+
"gemini-": "gemini.png",
44+
qwen: "qwen.png",
45+
codeqwen: "qwen.png",
46+
qwq: "qwen.png",
47+
command: "cohere.png",
48+
aya: "cohere.png",
49+
granite: "ibm.png",
50+
nemotron: "nvidia.png",
51+
kimi: "moonshot.png",
52+
glm: "zai.svg",
53+
codegeex: "zai.svg",
54+
wizardcoder: "wizardlm.png",
55+
wizardlm: "wizardlm.png",
56+
"wizard-": "wizardlm.png",
57+
olmo: "allenai.png",
58+
tulu: "allenai.png",
59+
firefunction: "fireworks.png",
60+
"gpt-oss": "openai.png",
61+
};
62+
63+
function getOllamaIcon(modelName: string): string {
64+
if (OLLAMA_ICON_MAP[modelName]) {
65+
return OLLAMA_ICON_MAP[modelName];
66+
}
67+
let bestMatch = "";
68+
for (const prefix of Object.keys(OLLAMA_ICON_MAP)) {
69+
if (modelName.startsWith(prefix) && prefix.length > bestMatch.length) {
70+
bestMatch = prefix;
71+
}
72+
}
73+
return bestMatch ? OLLAMA_ICON_MAP[bestMatch] : "ollama.png";
74+
}
75+
76+
async function fetchOllamaModels(): Promise<FetchedModel[]> {
77+
try {
78+
const response = await fetch("https://ollama.com/library");
79+
if (!response.ok) {
80+
throw new Error(`Failed to fetch Ollama library: ${response.status}`);
81+
}
82+
83+
const html = await response.text();
84+
const models: FetchedModel[] = [];
85+
const items = html.split("x-test-model class=");
86+
const seen = new Set<string>();
87+
88+
for (let i = 1; i < items.length; i++) {
89+
const item = items[i];
90+
const nameMatch = item.match(/href="\/library\/([^"]+)"/);
91+
if (!nameMatch) continue;
92+
const name = nameMatch[1];
93+
if (seen.has(name)) continue;
94+
95+
const capabilities: string[] = [];
96+
const capRegex = /x-test-capability[^>]*>([^<]+)</g;
97+
let capMatch;
98+
while ((capMatch = capRegex.exec(item)) !== null) {
99+
capabilities.push(capMatch[1].trim().toLowerCase());
100+
}
101+
if (
102+
capabilities.some((cap) => OLLAMA_EXCLUDED_CAPABILITIES.includes(cap))
103+
) {
104+
continue;
105+
}
106+
107+
const sizes: string[] = [];
108+
const sizeRegex = /x-test-size[^>]*>([^<]+)</g;
109+
let sizeMatch;
110+
while ((sizeMatch = sizeRegex.exec(item)) !== null) {
111+
sizes.push(sizeMatch[1].trim());
112+
}
113+
114+
const descMatch = item.match(/<p class="max-w-lg[^"]*">([^<]+)</);
115+
const sizeLabel = sizes.length > 0 ? ` (${sizes.join(", ")})` : "";
116+
const description = descMatch
117+
? descMatch[1].trim()
118+
: `Ollama model: ${name}${sizeLabel}`;
119+
120+
seen.add(name);
121+
models.push({
122+
name,
123+
description,
124+
icon: getOllamaIcon(name),
125+
popular: OLLAMA_POPULAR_PREFIXES.some((p) => name.startsWith(p)),
126+
supportsTools: capabilities.includes("tools"),
127+
});
128+
}
129+
130+
return models;
131+
} catch (error) {
132+
console.error("Error fetching Ollama library models:", error);
133+
return [];
134+
}
135+
}
136+
137+
async function fetchOpenRouterModels(): Promise<FetchedModel[]> {
138+
try {
139+
const response = await fetch("https://openrouter.ai/api/v1/models");
140+
if (!response.ok) {
141+
throw new Error(`Failed to fetch OpenRouter models: ${response.status}`);
142+
}
143+
144+
const data = await response.json();
145+
if (!data.data || !Array.isArray(data.data)) {
146+
return [];
147+
}
148+
149+
return data.data
150+
.filter((m: any) => m.id && m.name)
151+
.map((m: any) => ({
152+
name: m.name,
153+
modelId: m.id,
154+
icon: "openrouter.png",
155+
contextLength: m.context_length,
156+
maxTokens: m.top_provider?.max_completion_tokens,
157+
supportsTools: (m.supported_parameters ?? []).includes("tools"),
158+
}));
159+
} catch (error) {
160+
console.error("Error fetching OpenRouter models:", error);
161+
return [];
162+
}
163+
}
164+
165+
async function fetchAnthropicModels(apiKey?: string): Promise<FetchedModel[]> {
166+
const response = await fetch(
167+
"https://api.anthropic.com/v1/models?limit=100",
168+
{
169+
headers: {
170+
"x-api-key": apiKey ?? "",
171+
"anthropic-version": "2023-06-01",
172+
},
173+
},
174+
);
175+
if (!response.ok) {
176+
throw new Error(`Failed to fetch Anthropic models: ${response.status}`);
177+
}
178+
const data = await response.json();
179+
return (data.data ?? []).map((m: any) => ({
180+
name: m.display_name ?? m.id,
181+
modelId: m.id,
182+
icon: "anthropic.png",
183+
contextLength: m.max_input_tokens,
184+
maxTokens: m.max_tokens,
185+
supportsTools: true,
186+
}));
187+
}
188+
189+
async function fetchGeminiModels(
190+
apiKey?: string,
191+
apiBase?: string,
192+
): Promise<FetchedModel[]> {
193+
const base = apiBase || "https://generativelanguage.googleapis.com/v1beta/";
194+
const url = new URL("models", base);
195+
url.searchParams.set("key", apiKey ?? "");
196+
const response = await fetch(url);
197+
if (!response.ok) {
198+
throw new Error(`Failed to fetch Gemini models: ${response.status}`);
199+
}
200+
const data = await response.json();
201+
return (data.models ?? [])
202+
.filter((m: any) => {
203+
const id: string = m.name?.replace("models/", "") ?? "";
204+
const methods: string[] = m.supportedGenerationMethods ?? [];
205+
return (
206+
!id.startsWith("gemini-2.0") &&
207+
!id.startsWith("gemma-") && // Gemma models are supported through Ollama, not the Gemini API
208+
!id.startsWith("nano-banana") &&
209+
!id.startsWith("lyria") &&
210+
methods.includes("generateContent") &&
211+
!methods.includes("embedContent") &&
212+
!methods.includes("predict") &&
213+
!methods.includes("predictLongRunning") &&
214+
!methods.includes("bidiGenerateContent") &&
215+
!id.includes("tts") &&
216+
!id.includes("image") &&
217+
!id.includes("robotics") &&
218+
!id.includes("computer-use")
219+
);
220+
})
221+
.map((m: any) => ({
222+
name: m.displayName ?? m.name?.replace("models/", ""),
223+
modelId: m.name?.replace("models/", ""),
224+
icon: "gemini.png",
225+
contextLength: m.inputTokenLimit,
226+
maxTokens: m.outputTokenLimit,
227+
supportsTools: true,
228+
}));
229+
}
230+
231+
async function fetchProviderModelsViaListModels(
232+
provider: string,
233+
apiKey?: string,
234+
apiBase?: string,
235+
): Promise<FetchedModel[]> {
236+
try {
237+
const cls = LLMClasses.find((llm) => llm.providerName === provider);
238+
const defaultApiBase = cls?.defaultOptions?.apiBase;
239+
240+
const llm = llmFromProviderAndOptions(provider, {
241+
apiKey,
242+
apiBase: apiBase || defaultApiBase,
243+
model: "",
244+
});
245+
const modelIds = await llm.listModels();
246+
return modelIds.map((id) => ({ name: id }));
247+
} catch (error: any) {
248+
throw new Error(
249+
`Failed to fetch models for ${provider}: ${error?.message ?? error}`,
250+
);
251+
}
252+
}
253+
254+
export async function fetchModels(
255+
provider: string,
256+
apiKey?: string,
257+
apiBase?: string,
258+
): Promise<FetchedModel[]> {
259+
switch (provider) {
260+
case "ollama":
261+
return fetchOllamaModels();
262+
case "openrouter":
263+
return fetchOpenRouterModels();
264+
case "anthropic":
265+
return fetchAnthropicModels(apiKey);
266+
case "gemini":
267+
return fetchGeminiModels(apiKey, apiBase);
268+
default:
269+
return fetchProviderModelsViaListModels(provider, apiKey, apiBase);
270+
}
271+
}

core/llm/index.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ describe("BaseLLM", () => {
105105
baseLLM.model = "google/gemma-3-270m";
106106
expect(baseLLM.supportsImages()).toBe(true);
107107

108+
baseLLM.model = "gemma4:31b";
109+
expect(baseLLM.supportsImages()).toBe(true);
110+
111+
baseLLM.model = "google/gemma-4-31b-it";
112+
expect(baseLLM.supportsImages()).toBe(true);
113+
108114
baseLLM.model = "foo/paligemma-custom-100";
109115
expect(baseLLM.supportsImages()).toBe(true);
110116

core/llm/llms/OpenAI.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ class OpenAI extends BaseLLM {
400400
) {
401401
if (!this.apiBase) {
402402
throw new Error(
403-
"No API base URL provided. Please set the 'apiBase' option in config.json",
403+
"No API base URL provided. Please set the 'apiBase' option in config.yaml",
404404
);
405405
}
406406

@@ -700,7 +700,7 @@ class OpenAI extends BaseLLM {
700700
private _getEmbedEndpoint() {
701701
if (!this.apiBase) {
702702
throw new Error(
703-
"No API base URL provided. Please set the 'apiBase' option in config.json",
703+
"No API base URL provided. Please set the 'apiBase' option in config.yaml",
704704
);
705705
}
706706

core/llm/toolSupport.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe("PROVIDER_TOOL_SUPPORT", () => {
3333
it("should return true for Gemma models", () => {
3434
expect(supportsFn("ownerSlug/packageSlug/openai/gemma")).toBe(true);
3535
expect(supportsFn("ownerSlug/packageSlug/openai/gemma3")).toBe(true);
36+
expect(supportsFn("ownerSlug/packageSlug/openai/gemma4")).toBe(true);
3637
});
3738

3839
it("should return true for O3 models", () => {
@@ -62,6 +63,7 @@ describe("PROVIDER_TOOL_SUPPORT", () => {
6263
).toBe(true);
6364
expect(supportsFn("ownerSlug/packageSlug/openai/GPT-4-turbo")).toBe(true);
6465
expect(supportsFn("ownerSlug/packageSlug/openai/Gemma3")).toBe(true);
66+
expect(supportsFn("ownerSlug/packageSlug/openai/Gemma4")).toBe(true);
6567
expect(supportsFn("ownerSlug/packageSlug/gemini/GEMINI-pro")).toBe(true);
6668
});
6769
});
@@ -107,6 +109,7 @@ describe("PROVIDER_TOOL_SUPPORT", () => {
107109
it("should return true for Gemma models", () => {
108110
expect(supportsFn("gemma")).toBe(true);
109111
expect(supportsFn("gemma3")).toBe(true);
112+
expect(supportsFn("gemma4")).toBe(true);
110113
});
111114

112115
it("should return undefined for unsupported models", () => {
@@ -118,6 +121,7 @@ describe("PROVIDER_TOOL_SUPPORT", () => {
118121
expect(supportsFn("GPT-4-turbo")).toBe(true);
119122
expect(supportsFn("O3-preview")).toBe(true);
120123
expect(supportsFn("Gemma3")).toBe(true);
124+
expect(supportsFn("Gemma4")).toBe(true);
121125
});
122126
});
123127

core/llm/toolSupport.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ export const PROVIDER_TOOL_SUPPORT: Record<string, (model: string) => boolean> =
344344
"google/gemini-flash-1.5",
345345
"google/gemini-2",
346346
"google/gemini-3",
347+
"google/gemma-4",
347348
"google/gemini-pro",
348349
"x-ai/grok",
349350
"qwen/qwen3",

core/protocol/core.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,17 @@ export type ToCoreFromIdeOrWebviewProtocol = {
351351
"process/isBackgrounded": [{ toolCallId: string }, boolean];
352352
"process/killTerminalProcess": [{ toolCallId: string }, void];
353353
"mdm/setLicenseKey": [{ licenseKey: string }, boolean];
354+
"models/fetch": [
355+
{ provider: string; apiKey?: string; apiBase?: string },
356+
{
357+
name: string;
358+
modelId?: string;
359+
description?: string;
360+
icon?: string;
361+
popular?: boolean;
362+
contextLength?: number;
363+
maxTokens?: number;
364+
supportsTools?: boolean;
365+
}[],
366+
];
354367
};

0 commit comments

Comments
 (0)