Skip to content

Commit ba4e978

Browse files
committed
enable local Ollama model discovery via /api/tags
Wire up daemon provider-model discovery for the ollama protocol (GET /api/tags + response parser), gated to local/loopback base URLs so Ollama Cloud keeps its existing unsupported fallback. Remove the frontend short-circuits that blocked Fetch models for local Ollama across onboarding, settings, the inline switcher, and the avatar menu, reusing the existing isLocalOllamaBaseUrl helper. Local Ollama needs no API key. Adds daemon and web tests.
1 parent f580271 commit ba4e978

8 files changed

Lines changed: 173 additions & 18 deletions

File tree

apps/daemon/src/integrations/provider-models.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,29 @@ function parseModelCapability(value: unknown): ModelCapability | null {
178178
: null;
179179
}
180180

181+
function extractOllamaModels(data: unknown): ProviderModelOption[] {
182+
// Ollama's GET /api/tags returns { models: [{ name, model, ... }] } where
183+
// `name` is the pullable id (e.g. "llama3.3:70b").
184+
const items = (data as { models?: unknown }).models;
185+
if (!Array.isArray(items)) return [];
186+
return uniqueModels(
187+
items
188+
.map((item) => {
189+
const obj = item && typeof item === 'object'
190+
? (item as { name?: unknown; model?: unknown })
191+
: null;
192+
const id =
193+
typeof obj?.name === 'string' && obj.name.trim()
194+
? obj.name
195+
: typeof obj?.model === 'string'
196+
? obj.model
197+
: '';
198+
return id ? { id, label: id } : null;
199+
})
200+
.filter((item): item is ProviderModelOption => item != null),
201+
);
202+
}
203+
181204
function extractAnthropicModels(data: unknown): ProviderModelOption[] {
182205
const items = (data as { data?: unknown }).data;
183206
if (!Array.isArray(items)) return [];
@@ -252,6 +275,10 @@ function providerModelsUrl(protocol: ConnectionTestProtocol, baseUrl: string, ap
252275
if (protocol === 'google') {
253276
return googleProviderModelsUrl(baseUrl, apiKey);
254277
}
278+
if (protocol === 'ollama') {
279+
// Ollama lists locally-installed models at GET /api/tags (root path, not /v1).
280+
return new URL('/api/tags', baseUrl).toString();
281+
}
255282
throw new Error(`Unsupported protocol: ${protocol}`);
256283
}
257284

@@ -288,6 +315,7 @@ function extractModels(protocol: ConnectionTestProtocol, data: unknown): Provide
288315
if (protocol === 'openai' || protocol === 'senseaudio') return extractOpenAiModels(data);
289316
if (protocol === 'anthropic') return extractAnthropicModels(data);
290317
if (protocol === 'google') return extractGoogleModels(data);
318+
if (protocol === 'ollama') return extractOllamaModels(data);
291319
return [];
292320
}
293321

@@ -322,6 +350,16 @@ export async function listProviderModels(
322350
detail: 'AWS Bedrock uses a static seed until AWS credential-backed discovery is available.',
323351
};
324352
}
353+
if (input.protocol === 'ollama' && !isLoopbackApiHost(validated.parsed.hostname)) {
354+
// Discovery via /api/tags is a local-Ollama capability. Ollama Cloud
355+
// (ollama.com) does not expose it, so keep the existing unsupported path.
356+
return {
357+
ok: false,
358+
kind: 'unsupported_protocol',
359+
latencyMs: Date.now() - start,
360+
detail: 'Ollama model discovery is only available for local (loopback) endpoints.',
361+
};
362+
}
325363

326364
let url: string;
327365
try {

apps/daemon/src/routes/chat.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '../integrations/aihubmix.js';
3333
import { isSafeId as isSafeProjectId } from '../projects.js';
3434
import { projectKindToTracking } from '@open-design/contracts/analytics';
35+
import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
3536
import { proxyDispatcherRequestInit, validateUserProviderBaseUrl } from '../connectionTest.js';
3637
import { resolveModelForServiceTier } from '../runtimes/models.js';
3738
import { googleStreamGenerateContentUrl } from '../integrations/google-models.js';
@@ -217,9 +218,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
217218
);
218219
}
219220
// AIHubMix's catalogue (GET /api/v1/models?type=llm) is public, so its
220-
// model list loads without a key. Every other protocol needs the key to
221-
// hit its /v1/models endpoint.
222-
const apiKeyRequired = protocol !== 'aihubmix' && protocol !== 'bedrock';
221+
// model list loads without a key. Local Ollama (GET /api/tags) is also
222+
// unauthenticated. Every other protocol needs the key to hit its
223+
// /v1/models endpoint.
224+
const isLocalOllamaDiscovery =
225+
protocol === 'ollama' &&
226+
typeof body.baseUrl === 'string' &&
227+
(() => {
228+
try {
229+
return isLoopbackApiHost(new URL(body.baseUrl).hostname);
230+
} catch {
231+
return false;
232+
}
233+
})();
234+
const apiKeyRequired =
235+
protocol !== 'aihubmix' && protocol !== 'bedrock' && !isLocalOllamaDiscovery;
223236
if (
224237
typeof body.baseUrl !== 'string' ||
225238
typeof body.apiKey !== 'string' ||

apps/daemon/tests/connection-test.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,83 @@ describe('POST /api/provider/models', () => {
647647
}
648648
});
649649

650+
it('lists local Ollama models from /api/tags', async () => {
651+
// Loopback is opted in so the request reaches upstream regardless of the
652+
// daemon's internal-host policy.
653+
vi.stubEnv('OD_ALLOWED_INTERNAL_HOSTS', '127.0.0.1');
654+
const fetchMock = passThroughOrUpstream((url) => {
655+
expect(url).toBe('http://127.0.0.1:11434/api/tags');
656+
return jsonResponse({
657+
models: [
658+
{ name: 'llama3.3:70b', model: 'llama3.3:70b' },
659+
{ name: 'qwen3-coder:480b' },
660+
],
661+
});
662+
});
663+
vi.stubGlobal('fetch', fetchMock);
664+
try {
665+
const res = await realFetch(`${baseUrl}/api/provider/models`, {
666+
method: 'POST',
667+
headers: { 'content-type': 'application/json' },
668+
body: JSON.stringify({
669+
protocol: 'ollama',
670+
baseUrl: 'http://127.0.0.1:11434',
671+
apiKey: '',
672+
}),
673+
});
674+
const body = (await res.json()) as {
675+
ok: boolean;
676+
kind: string;
677+
models?: Array<Record<string, unknown>>;
678+
};
679+
expect(body).toMatchObject({
680+
ok: true,
681+
kind: 'success',
682+
models: [
683+
{ id: 'llama3.3:70b', label: 'llama3.3:70b' },
684+
{ id: 'qwen3-coder:480b', label: 'qwen3-coder:480b' },
685+
],
686+
});
687+
} finally {
688+
vi.unstubAllEnvs();
689+
}
690+
});
691+
692+
it('rejects Ollama Cloud model discovery without calling upstream fetch', async () => {
693+
const dnsSpy = vi
694+
.spyOn(dnsPromises, 'lookup')
695+
.mockImplementation((async (hostname: string) => {
696+
if (hostname === 'ollama.com') {
697+
return [{ address: '104.18.0.1', family: 4 }];
698+
}
699+
const err: NodeJS.ErrnoException = new Error('ENOTFOUND');
700+
err.code = 'ENOTFOUND';
701+
throw err;
702+
}) as unknown as typeof dnsPromises.lookup);
703+
const fetchMock = passThroughOrUpstream(() => jsonResponse({ models: [] }));
704+
vi.stubGlobal('fetch', fetchMock);
705+
try {
706+
const res = await realFetch(`${baseUrl}/api/provider/models`, {
707+
method: 'POST',
708+
headers: { 'content-type': 'application/json' },
709+
body: JSON.stringify({
710+
protocol: 'ollama',
711+
baseUrl: 'https://ollama.com',
712+
apiKey: 'ollama-key',
713+
}),
714+
});
715+
const body = (await res.json()) as Record<string, unknown>;
716+
expect(body).toMatchObject({ ok: false, kind: 'unsupported_protocol' });
717+
expect(
718+
fetchMock.mock.calls.some(
719+
([input]) => !String(input).startsWith(baseUrl),
720+
),
721+
).toBe(false);
722+
} finally {
723+
dnsSpy.mockRestore();
724+
}
725+
});
726+
650727
it('reports timeout when model listing is aborted by the probe timer', async () => {
651728
// The DNS-aware validator runs before the probe timer is installed; stub
652729
// the resolver so the test doesn't race against real DNS while fake

apps/web/src/components/AvatarMenu.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from './providerModelsCache';
2020
import { KNOWN_PROVIDERS } from '../state/config';
2121
import { SUGGESTED_MODELS_BY_PROTOCOL } from '../state/apiProtocols';
22+
import { isLocalOllamaBaseUrl } from '../utils/byokProvider';
2223
import { fetchProviderModels } from '../providers/provider-models';
2324
import type { AgentInfo, AppConfig, ExecMode, ProviderModelOption } from '../types';
2425
import {
@@ -364,11 +365,12 @@ export function AvatarMenu({
364365
useEffect(() => {
365366
if (!open || config.mode !== 'api') return;
366367
if (fetchedByokModels.length > 0) return;
367-
if (apiProtocol === 'azure' || apiProtocol === 'ollama') return;
368368
const baseUrl = config.baseUrl?.trim() ?? '';
369+
const isLocalOllama = apiProtocol === 'ollama' && isLocalOllamaBaseUrl(baseUrl);
370+
if (apiProtocol === 'azure' || (apiProtocol === 'ollama' && !isLocalOllama)) return;
369371
if (!/^https?:\/\//i.test(baseUrl)) return;
370-
// AIHubMix's catalogue is public; every other protocol needs a key.
371-
if (apiProtocol !== 'aihubmix' && !(config.apiKey ?? '').trim()) return;
372+
// AIHubMix is public and local Ollama needs no key; other protocols require one.
373+
if (apiProtocol !== 'aihubmix' && !isLocalOllama && !(config.apiKey ?? '').trim()) return;
372374
const key = byokModelsKey;
373375
let cancelled = false;
374376
void fetchProviderModels({

apps/web/src/components/EntryShell.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ import {
211211
import { closeAmrActivationWindowBestEffort } from './AmrLoginPill';
212212
import { smoothScrollToTop } from '../utils/smoothScrollToTop';
213213
import { summarizeProjectNameFromPrompt } from '../utils/projectName';
214+
import { isLocalOllamaBaseUrl } from '../utils/byokProvider';
214215
import { LIBRARY_UI_VISIBLE } from '../features/libraryUi';
215216
import {
216217
providerModelsCacheKey,
@@ -2061,10 +2062,12 @@ function OnboardingView({
20612062
Boolean(config.apiKey.trim()) &&
20622063
Boolean(config.baseUrl.trim()) &&
20632064
Boolean(config.model.trim());
2065+
const isLocalOllamaProvider =
2066+
apiProtocol === 'ollama' && isLocalOllamaBaseUrl(config.baseUrl);
20642067
const canFetchProviderModels =
20652068
apiProtocol !== 'azure' &&
2066-
apiProtocol !== 'ollama' &&
2067-
Boolean(config.apiKey.trim()) &&
2069+
(apiProtocol !== 'ollama' || isLocalOllamaProvider) &&
2070+
(isLocalOllamaProvider || Boolean(config.apiKey.trim())) &&
20682071
Boolean(config.baseUrl.trim()) &&
20692072
isLikelyHttpUrl(config.baseUrl);
20702073
const visibleProviderTestState =

apps/web/src/components/InlineModelSwitcher.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
import type { AgentInfo, ApiProtocol, AppConfig, ExecMode } from '../types';
6767
import { apiProtocolLabel } from '../utils/apiProtocol';
6868
import { isVisibleLocalCliAgent } from '../utils/visibleAgents';
69+
import { isLocalOllamaBaseUrl } from '../utils/byokProvider';
6970
import { AgentIcon } from './AgentIcon';
7071
import { Icon } from './Icon';
7172
import { modelProviderIconSrc } from './modelProviderIcon';
@@ -986,8 +987,9 @@ export function InlineModelSwitcher({
986987
// serves both surfaces and replaces any stale slot.
987988
useEffect(() => {
988989
if (!open || config.mode !== 'api' || !onProviderModelsCacheChange) return;
989-
if (apiProtocol === 'azure' || apiProtocol === 'ollama') return;
990-
if (apiProtocol !== 'aihubmix' && !config.apiKey.trim()) return;
990+
const isLocalOllama = apiProtocol === 'ollama' && isLocalOllamaBaseUrl(config.baseUrl);
991+
if (apiProtocol === 'azure' || (apiProtocol === 'ollama' && !isLocalOllama)) return;
992+
if (apiProtocol !== 'aihubmix' && !isLocalOllama && !config.apiKey.trim()) return;
991993
const baseUrl = config.baseUrl.trim();
992994
if (!/^https?:\/\//i.test(baseUrl)) return;
993995
const key = providerModelsKey;

apps/web/src/components/SettingsDialog.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ import {
134134
import { MEDIA_PROVIDERS } from '../media/models';
135135
import { useByokImageModelOptions, useByokVideoModelOptions, useByokSpeechModelOptions } from '../media/aihubmix-image-models';
136136
import { isVisualStabilityMode } from '../utils/visualStability';
137-
import { byokProviderRequiresApiKey } from '../utils/byokProvider';
137+
import { byokProviderRequiresApiKey, isLocalOllamaBaseUrl } from '../utils/byokProvider';
138138
import { XaiOAuthControl } from './XaiOAuthControl';
139139
import type { MediaProvider } from '../media/models';
140140
import { Toast } from './Toast';
@@ -698,11 +698,11 @@ export function canFetchProviderModels(
698698
config: Pick<AppConfig, 'apiKey' | 'baseUrl'>,
699699
protocol: ApiProtocol,
700700
): boolean {
701+
const isLocalOllama = protocol === 'ollama' && isLocalOllamaBaseUrl(config.baseUrl);
701702
return (
702703
!isProviderModelDiscoveryUnsupported(protocol, config.baseUrl) &&
703704
protocol !== 'azure' &&
704-
protocol !== 'ollama' &&
705-
(protocol === 'bedrock' || Boolean(config.apiKey.trim())) &&
705+
(protocol === 'bedrock' || isLocalOllama || Boolean(config.apiKey.trim())) &&
706706
Boolean(config.baseUrl.trim()) &&
707707
isValidApiBaseUrl(config.baseUrl)
708708
);
@@ -712,7 +712,10 @@ export function isProviderModelDiscoveryUnsupported(
712712
protocol: ApiProtocol,
713713
baseUrl: string,
714714
): boolean {
715-
if (protocol === 'azure' || protocol === 'ollama') return true;
715+
if (protocol === 'azure') return true;
716+
// Ollama model discovery (GET /api/tags) is a local-only capability; Ollama
717+
// Cloud (ollama.com) does not expose it.
718+
if (protocol === 'ollama') return !isLocalOllamaBaseUrl(baseUrl);
716719
try {
717720
const host = new URL(baseUrl).hostname.toLowerCase();
718721
return host === 'token-plan-cn.xiaomimimo.com';
@@ -1842,7 +1845,7 @@ export function SettingsDialog({
18421845
if (
18431846
initial.mode !== 'api' ||
18441847
protocol === 'azure' ||
1845-
protocol === 'ollama' ||
1848+
(protocol === 'ollama' && !isLocalOllamaBaseUrl(initial.baseUrl)) ||
18461849
missingByokModelFetchFields(initial, protocol).length > 0 ||
18471850
!isValidApiBaseUrl(initial.baseUrl)
18481851
) {
@@ -2733,7 +2736,7 @@ export function SettingsDialog({
27332736
}
27342737
return;
27352738
}
2736-
if (apiProtocol === 'ollama') {
2739+
if (apiProtocol === 'ollama' && !isLocalOllamaBaseUrl(cfg.baseUrl)) {
27372740
trackModelsFetchResult({
27382741
result: 'failed',
27392742
error_code: 'unsupported_ollama',
@@ -3514,11 +3517,9 @@ export function SettingsDialog({
35143517
);
35153518
const providerModelDiscoveryUnavailable =
35163519
apiProtocol !== 'azure' &&
3517-
apiProtocol !== 'ollama' &&
35183520
isProviderModelDiscoveryUnsupported(apiProtocol, cfg.baseUrl);
35193521
const providerModelDiscoverySupported =
35203522
apiProtocol !== 'azure' &&
3521-
apiProtocol !== 'ollama' &&
35223523
!providerModelDiscoveryUnavailable;
35233524
const fetchedApiModelOptions =
35243525
providerModelDiscoveryUnavailable

apps/web/tests/components/SettingsDialog.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,25 @@ describe('SettingsDialog provider model fetch helpers', () => {
578578
'ollama',
579579
),
580580
).toBe(false);
581+
// Local Ollama supports discovery via GET /api/tags and needs no API key.
582+
expect(
583+
canFetchProviderModels(
584+
{ apiKey: '', baseUrl: 'http://localhost:11434' },
585+
'ollama',
586+
),
587+
).toBe(true);
588+
expect(
589+
canFetchProviderModels(
590+
{ apiKey: '', baseUrl: 'http://127.0.0.1:11434' },
591+
'ollama',
592+
),
593+
).toBe(true);
594+
expect(
595+
isProviderModelDiscoveryUnsupported('ollama', 'http://localhost:11434'),
596+
).toBe(false);
597+
expect(
598+
isProviderModelDiscoveryUnsupported('ollama', 'https://ollama.com'),
599+
).toBe(true);
581600
expect(
582601
canFetchProviderModels(
583602
{

0 commit comments

Comments
 (0)