Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions apps/sim/app/api/providers/ollama/models/route.test.ts
Original file line number Diff line number Diff line change
@@ -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(<T>(_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()
})
})
65 changes: 51 additions & 14 deletions apps/sim/app/api/providers/ollama/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)
Expand All @@ -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,
})
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/core/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading