diff --git a/apps/sim/app/api/providers/ollama/models/route.test.ts b/apps/sim/app/api/providers/ollama/models/route.test.ts new file mode 100644 index 00000000000..b1b37f155b7 --- /dev/null +++ b/apps/sim/app/api/providers/ollama/models/route.test.ts @@ -0,0 +1,164 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFilterBlacklistedModels, + mockIsProviderBlacklisted, + mockFetch, + mockIsOllamaUrlConfigured, + ollamaLogger, +} = vi.hoisted(() => ({ + mockFilterBlacklistedModels: vi.fn(), + mockIsProviderBlacklisted: vi.fn(), + mockFetch: vi.fn(), + mockIsOllamaUrlConfigured: vi.fn(), + ollamaLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => ollamaLogger), + logger: ollamaLogger, + runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), + getRequestContext: vi.fn(() => undefined), +})) + +vi.mock('@/providers/utils', () => ({ + filterBlacklistedModels: mockFilterBlacklistedModels, + isProviderBlacklisted: mockIsProviderBlacklisted, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getOllamaUrl: () => 'http://localhost:11434', + isOllamaUrlConfigured: mockIsOllamaUrlConfigured, +})) + +import { GET } from '@/app/api/providers/ollama/models/route' + +const request = () => createMockRequest('GET') + +describe('ollama models route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsOllamaUrlConfigured.mockReturnValue(false) + mockIsProviderBlacklisted.mockReturnValue(false) + mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) + vi.stubGlobal('fetch', mockFetch) + setEnvFlags({ isHosted: false }) + }) + + afterAll(() => { + vi.unstubAllGlobals() + resetEnvFlagsMock() + }) + + it('does not probe a loopback Ollama on the hosted platform', async () => { + setEnvFlags({ isHosted: true }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('still honours an explicit OLLAMA_URL on the hosted platform', async () => { + setEnvFlags({ isHosted: true }) + mockIsOllamaUrlConfigured.mockReturnValue(true) + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ models: [{ name: 'llama3' }] }) }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: ['llama3'] }) + expect(mockFetch).toHaveBeenCalled() + }) + + it('probes the default host when self-hosted, so no configuration is required', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ models: [{ name: 'llama3' }] }) }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: ['llama3'] }) + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/tags'), expect.anything()) + }) + + it('reports an unreachable Ollama as an empty list rather than a failure', async () => { + /** + * A deployment that runs no Ollama refuses this connection on every poll. The + * level is the point: an optional service being absent is not an error. + */ + mockFetch.mockRejectedValue(new Error('Unable to connect. Is the computer able to access it?')) + + const response = await GET(request()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(ollamaLogger.error).not.toHaveBeenCalled() + expect(ollamaLogger.info).toHaveBeenCalledWith( + 'Ollama service is not reachable, returning empty models', + expect.objectContaining({ host: expect.any(String) }) + ) + }) + + it('returns nothing when the provider is blacklisted', async () => { + mockIsProviderBlacklisted.mockReturnValue(true) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('reports an unreadable response as an error, not as absence', async () => { + /** + * Something answered 2xx but did not return a tag listing. Unlike a refused + * connection that is a real fault, and must not be filed under "no Ollama here". + */ + mockFetch.mockResolvedValue({ + ok: true, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0') + }, + }) + + const response = await GET(request()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(ollamaLogger.error).toHaveBeenCalledWith( + 'Ollama returned a response this route cannot read', + expect.objectContaining({ host: expect.any(String) }) + ) + }) + + it('reports a non-2xx response as unavailable', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(ollamaLogger.warn).toHaveBeenCalled() + expect(ollamaLogger.error).not.toHaveBeenCalled() + }) + + it('reports a wrongly-shaped tag listing as an error', async () => { + /** Reachable and 2xx, but the entries are not Ollama models. */ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ models: [{ noName: true }] }) }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(ollamaLogger.error).toHaveBeenCalled() + }) + + it('accepts a listing with no models as simply empty', async () => { + /** The schema defaults `models` to [], so an empty answer is not a fault. */ + mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }) + + const response = await GET(request()) + + await expect(response.json()).resolves.toEqual({ models: [] }) + expect(ollamaLogger.error).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/providers/ollama/models/route.ts b/apps/sim/app/api/providers/ollama/models/route.ts index 47c302ba28b..13274f69d6e 100644 --- a/apps/sim/app/api/providers/ollama/models/route.ts +++ b/apps/sim/app/api/providers/ollama/models/route.ts @@ -5,7 +5,8 @@ import { ollamaUpstreamResponseSchema, providerModelsResponseSchema, } from '@/lib/api/contracts/providers' -import { getOllamaUrl } from '@/lib/core/utils/urls' +import { isHosted } from '@/lib/core/config/env-flags' +import { getOllamaUrl, isOllamaUrlConfigured } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' @@ -21,26 +22,61 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [] }) } - try { - logger.info('Fetching Ollama models', { - host: OLLAMA_HOST, - }) + /** + * Ollama runs alongside the app it serves, so the hosted platform never has one + * and `OLLAMA_URL`'s loopback default cannot answer there. Skip the probe rather + * than dial an address known to refuse on every poll. + * + * Only the unconfigured default is skipped: an explicit `OLLAMA_URL` states an + * intent to reach a real server and is still honoured. Self-hosted deployments + * are untouched either way, including the localhost default that needs no + * configuration to work. + */ + if (isHosted && !isOllamaUrlConfigured()) { + logger.info('Ollama is not available on the hosted platform, returning empty models') + return NextResponse.json({ models: [] }) + } - const response = await fetch(`${OLLAMA_HOST}/api/tags`, { + logger.info('Fetching Ollama models', { + host: OLLAMA_HOST, + }) + + let response: Response + try { + response = await fetch(`${OLLAMA_HOST}/api/tags`, { headers: { 'Content-Type': 'application/json', }, next: { revalidate: 60 }, }) + } catch (error) { + /** + * Ollama is optional, so a deployment that does not run one refuses the + * connection on every poll. That is an expected state rather than a failure of + * this route — the same condition its siblings report when `VLLM_BASE_URL` or + * `LITELLM_BASE_URL` is absent — and the response is the same empty list a + * blacklisted provider returns. + * + * Scoped to the connection itself: a server that answers but answers wrongly is + * a real fault and is reported as one below. + */ + logger.info('Ollama service is not reachable, returning empty models', { + error: getErrorMessage(error, 'Unknown error'), + host: OLLAMA_HOST, + }) - if (!response.ok) { - logger.warn('Ollama service is not available', { - status: response.status, - statusText: response.statusText, - }) - return NextResponse.json({ models: [] }) - } + return NextResponse.json({ models: [] }) + } + if (!response.ok) { + logger.warn('Ollama service is not available', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json({ models: [] }) + } + + try { const data = ollamaUpstreamResponseSchema.parse(await response.json()) const allModels = data.models.map((model) => model.name) const models = filterBlacklistedModels(allModels) @@ -53,7 +89,8 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json(providerModelsResponseSchema.parse({ models })) } catch (error) { - logger.error('Failed to fetch Ollama models', { + /** Something is listening and returned 2xx, but not an Ollama tag listing. */ + logger.error('Ollama returned a response this route cannot read', { error: getErrorMessage(error, 'Unknown error'), host: OLLAMA_HOST, }) diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 5f13016e80b..62eed93d50a 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -245,3 +245,12 @@ export function getSocketUrl(): string { export function getOllamaUrl(): string { return env.OLLAMA_URL || DEFAULT_OLLAMA_URL } + +/** + * Whether OLLAMA_URL names a server, as opposed to {@link getOllamaUrl} falling + * back to the loopback default. Callers use this to tell "someone pointed us at + * an Ollama" apart from "nobody configured one". + */ +export function isOllamaUrlConfigured(): boolean { + return Boolean(env.OLLAMA_URL) +}