From 6bd167e7d2d8f8a2b6f028bdc2c8f79203d1ef16 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 12:39:38 -0700 Subject: [PATCH 1/2] fix(providers): stop reporting an absent Ollama as an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama is optional and its URL falls back to a loopback default, so a deployment that runs none refuses the probe on every poll — 10,068 of these in 14 days, the single largest error stream in the app, all of them the same expected condition. Report it the way the vLLM and LiteLLM routes already report an unconfigured base URL, and skip the probe entirely on the hosted platform, which has no local runtime to reach. An explicit OLLAMA_URL is still honoured everywhere, so a self-hosted deployment behaves exactly as before — including the localhost default that requires no configuration. --- .../api/providers/ollama/models/route.test.ts | 112 ++++++++++++++++++ .../app/api/providers/ollama/models/route.ts | 27 ++++- apps/sim/lib/core/utils/urls.ts | 9 ++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/providers/ollama/models/route.test.ts 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..91c650b6b1d --- /dev/null +++ b/apps/sim/app/api/providers/ollama/models/route.test.ts @@ -0,0 +1,112 @@ +/** + * @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() + }) +}) diff --git a/apps/sim/app/api/providers/ollama/models/route.ts b/apps/sim/app/api/providers/ollama/models/route.ts index 47c302ba28b..55c85cd4937 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,6 +22,21 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [] }) } + /** + * 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: [] }) + } + try { logger.info('Fetching Ollama models', { host: OLLAMA_HOST, @@ -53,7 +69,14 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json(providerModelsResponseSchema.parse({ models })) } catch (error) { - logger.error('Failed to fetch Ollama models', { + /** + * 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. + */ + logger.info('Ollama service is not reachable, returning empty models', { 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) +} From ee8aa7f4382e3e7458ab9d2be982b540cbdce4d2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 12:45:16 -0700 Subject: [PATCH 2/2] fix(providers): keep an unreadable Ollama response out of the not-reachable path The single catch covered the connection, the JSON read, and the schema parse, so a server that answered but answered wrongly was filed as 'no Ollama here'. Scope the quiet path to the connection itself and report an unusable response as the fault it is. --- .../api/providers/ollama/models/route.test.ts | 52 ++++++++++++++++++ .../app/api/providers/ollama/models/route.ts | 54 ++++++++++++------- 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/providers/ollama/models/route.test.ts b/apps/sim/app/api/providers/ollama/models/route.test.ts index 91c650b6b1d..b1b37f155b7 100644 --- a/apps/sim/app/api/providers/ollama/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama/models/route.test.ts @@ -109,4 +109,56 @@ describe('ollama models route', () => { 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 55c85cd4937..13274f69d6e 100644 --- a/apps/sim/app/api/providers/ollama/models/route.ts +++ b/apps/sim/app/api/providers/ollama/models/route.ts @@ -37,26 +37,46 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json({ models: [] }) } - try { - logger.info('Fetching Ollama models', { - host: OLLAMA_HOST, - }) + logger.info('Fetching Ollama models', { + host: OLLAMA_HOST, + }) - const response = await fetch(`${OLLAMA_HOST}/api/tags`, { + 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) @@ -69,14 +89,8 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { return NextResponse.json(providerModelsResponseSchema.parse({ models })) } 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. - */ - logger.info('Ollama service is not reachable, returning empty 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, })