Skip to content

Commit 9f9ded7

Browse files
authored
Merge pull request #12046 from RomneyDa/dynamic-model-fetching
feat: fetch provider models dynamically and fix isOpenSource pollution
2 parents a1ead04 + bcd29eb commit 9f9ded7

21 files changed

Lines changed: 708 additions & 208 deletions

File tree

core/config/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,20 @@ export function addModel(
7070
config.models = [];
7171
}
7272

73+
const capabilities: string[] = [];
74+
if (model.capabilities?.tools) capabilities.push("tool_use");
75+
if (model.capabilities?.uploadImage) capabilities.push("image_input");
76+
7377
const desc: ModelConfig = {
7478
name: model.title,
7579
provider: model.provider,
7680
model: model.model,
7781
apiKey: model.apiKey,
7882
apiBase: model.apiBase,
83+
contextLength: model.contextLength,
7984
maxStopWords: model.maxStopWords,
8085
defaultCompletionOptions: model.completionOptions,
86+
...(capabilities.length > 0 ? { capabilities } : {}),
8187
};
8288
config.models.push(desc);
8389
return config;

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/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,7 @@ export interface JSONModelDescription {
17241724
maxStopWords?: number;
17251725
template?: TemplateType;
17261726
completionOptions?: BaseCompletionOptions;
1727+
capabilities?: ModelCapability;
17271728
systemMessage?: string;
17281729
requestOptions?: RequestOptions;
17291730
cacheBehavior?: CacheBehavior;

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

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/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,7 @@ export abstract class BaseLLM implements ILLM {
10481048
this.providerName === "openai" &&
10491049
this._llmOptions.useResponsesApi !== false &&
10501050
typeof (this as any)._streamResponses === "function" &&
1051-
(this as any).isOSeriesOrGpt5Model(options.model)
1051+
(this as any).isOSeriesOrGpt5PlusModel(options.model)
10521052
);
10531053
}
10541054

0 commit comments

Comments
 (0)