mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
Merge 149ee80f82 into ec074519d9
This commit is contained in:
commit
3826c2ed86
|
|
@ -1214,11 +1214,36 @@ describe('createModel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('unsupported provider', () => {
|
||||
it('should throw for ollama', () => {
|
||||
expect(() => createModel('ollama/llama3')).toThrow(/Unsupported provider: "ollama"/);
|
||||
describe('ollama', () => {
|
||||
it('should create a model through the OpenAI-compatible provider', () => {
|
||||
const model = createModel({
|
||||
id: 'ollama/llama3.1',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'http://localhost:11434',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.provider).toBe('ollama');
|
||||
expect(model.modelId).toBe('llama3.1');
|
||||
expect(model.apiKey).toBe('test-key');
|
||||
expect(model.baseURL).toBe('http://localhost:11434/v1');
|
||||
});
|
||||
|
||||
it('should default the baseURL to the local server when unset', () => {
|
||||
const model = createModel({ id: 'ollama/llama3.1' }) as unknown as Record<string, unknown>;
|
||||
expect(model.baseURL).toBe('http://localhost:11434/v1');
|
||||
expect(model.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should keep a baseURL that already ends in /v1', () => {
|
||||
const model = createModel({
|
||||
id: 'ollama/llama3.1',
|
||||
baseURL: 'https://ollama.example.com/v1',
|
||||
}) as unknown as Record<string, unknown>;
|
||||
expect(model.baseURL).toBe('https://ollama.example.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported provider', () => {
|
||||
|
||||
it('should include supported providers in the error message', () => {
|
||||
expect(() => createModel('unknown-provider/some-model')).toThrow(/Supported providers:/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -370,6 +370,24 @@ const LANGUAGE_PROVIDERS: ProviderRegistry = {
|
|||
},
|
||||
},
|
||||
nvidia: openAiCompatibleEntry('nvidia', 'https://integrate.api.nvidia.com/v1', {}),
|
||||
ollama: {
|
||||
build: (creds, model, fetch) => {
|
||||
// Ollama serves its OpenAI-compatible API under `/v1`, but the
|
||||
// `ollamaApi` credential stores the bare server host
|
||||
// (e.g. `http://localhost:11434`), like the LmChatOllama node.
|
||||
const normalizedBaseURL = ensureUrlPathSuffix(
|
||||
creds.baseURL ?? 'http://localhost:11434',
|
||||
'/v1',
|
||||
);
|
||||
return buildOpenAiCompatible(
|
||||
'ollama',
|
||||
undefined,
|
||||
{ ...creds, baseURL: normalizedBaseURL },
|
||||
model,
|
||||
fetch,
|
||||
);
|
||||
},
|
||||
},
|
||||
moonshotai: {
|
||||
build: (creds, model, fetch) => {
|
||||
const { createMoonshotAI } =
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ export const PROVIDER_CREDENTIAL_SCHEMAS = {
|
|||
vercel: apiKeyCreds,
|
||||
openrouter: apiKeyCreds,
|
||||
nvidia: apiKeyCreds,
|
||||
/**
|
||||
* The API key is optional: a default Ollama install accepts unauthenticated
|
||||
* local requests, and proxies like Open WebUI take a bearer token. This mirrors
|
||||
* the `ollamaApi` credential, whose only required field is `baseUrl`.
|
||||
*/
|
||||
ollama: apiKeyCreds,
|
||||
|
||||
'azure-openai': z
|
||||
.object({
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ describe('model-discovery', () => {
|
|||
'mistral',
|
||||
'moonshotai',
|
||||
'nvidia',
|
||||
'ollama',
|
||||
'openai',
|
||||
'openrouter',
|
||||
'vercel',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { listModelsForProvider } from '../index';
|
||||
|
||||
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function calledUrl(fetchFn: unknown): string {
|
||||
return String((fetchFn as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||
}
|
||||
|
||||
function calledHeaders(fetchFn: unknown): Record<string, string> {
|
||||
const init = (fetchFn as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
return init.headers;
|
||||
}
|
||||
|
||||
describe('ollama', () => {
|
||||
it('lists model names from the user server /api/tags, sorted by name', async () => {
|
||||
const fetch = mockFetch({
|
||||
models: [
|
||||
{ name: 'qwen3:8b', modified_at: '2026-01-01T00:00:00Z', size: 1 },
|
||||
{ name: 'llama3.2', modified_at: '2025-01-01T00:00:00Z', size: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const models = await listModelsForProvider('ollama', {
|
||||
apiKey: '',
|
||||
baseURL: 'http://my-server:11434/',
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(calledUrl(fetch)).toBe('http://my-server:11434/api/tags');
|
||||
expect(calledHeaders(fetch).Authorization).toBeUndefined();
|
||||
expect(models).toEqual([
|
||||
{ id: 'llama3.2', name: 'llama3.2' },
|
||||
{ id: 'qwen3:8b', name: 'qwen3:8b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the bearer header only when an API key is configured', async () => {
|
||||
const fetch = mockFetch({ models: [{ name: 'llama3.2' }] });
|
||||
|
||||
await listModelsForProvider('ollama', {
|
||||
apiKey: 'proxy-key',
|
||||
baseURL: 'https://ollama.example.com',
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(calledHeaders(fetch).Authorization).toBe('Bearer proxy-key');
|
||||
});
|
||||
|
||||
it('falls back to the default local URL without a credential base URL', async () => {
|
||||
const fetch = mockFetch({ models: [{ name: 'llama3.2' }] });
|
||||
|
||||
await listModelsForProvider('ollama', { apiKey: '', fetch });
|
||||
|
||||
expect(calledUrl(fetch)).toBe('http://localhost:11434/api/tags');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { listMistralModels } from './providers/mistral';
|
|||
import { listMoonshotAiModels } from './providers/moonshotai';
|
||||
import { listNvidiaModels } from './providers/nvidia';
|
||||
import { listOpenAiModels } from './providers/openai';
|
||||
import { listOllamaModels } from './providers/ollama';
|
||||
import { listOpenRouterModels } from './providers/openrouter';
|
||||
import { listVercelModels } from './providers/vercel';
|
||||
import { listXaiModels } from './providers/xai';
|
||||
|
|
@ -36,6 +37,7 @@ export const MODEL_DISCOVERY_PROVIDERS: Record<string, ListModelsFn> = {
|
|||
mistral: listMistralModels,
|
||||
moonshotai: listMoonshotAiModels,
|
||||
nvidia: listNvidiaModels,
|
||||
ollama: listOllamaModels,
|
||||
openai: listOpenAiModels,
|
||||
openrouter: listOpenRouterModels,
|
||||
vercel: listVercelModels,
|
||||
|
|
@ -83,6 +85,7 @@ export { listMiniMaxModels } from './providers/minimax';
|
|||
export { listMistralModels } from './providers/mistral';
|
||||
export { listMoonshotAiModels } from './providers/moonshotai';
|
||||
export { listNvidiaModels } from './providers/nvidia';
|
||||
export { listOllamaModels } from './providers/ollama';
|
||||
export {
|
||||
listOpenAiModels,
|
||||
shouldIncludeOpenAiModel,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { baseUrl, getJson } from '../request';
|
||||
import type { ListModelsFn, ProviderModel } from '../types';
|
||||
|
||||
/**
|
||||
* Source: LmChatOllama `loadOptions` routing — the model names served by the
|
||||
* user's own Ollama server at `GET {baseUrl}/api/tags`.
|
||||
*/
|
||||
export const listOllamaModels: ListModelsFn = async (options) => {
|
||||
// The node only sends the bearer header when an API key is configured;
|
||||
// a default Ollama install accepts unauthenticated local requests.
|
||||
const headers = options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {};
|
||||
const data = (await getJson(
|
||||
`${baseUrl(options, 'http://localhost:11434')}/api/tags`,
|
||||
headers,
|
||||
options,
|
||||
'ollama',
|
||||
)) as { models?: Array<{ name?: unknown }> };
|
||||
|
||||
const models = (data.models ?? [])
|
||||
.filter(
|
||||
(model): model is { name: string } =>
|
||||
typeof model.name === 'string' && model.name !== '',
|
||||
)
|
||||
.map((model): ProviderModel => ({ id: model.name, name: model.name }));
|
||||
|
||||
return models.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
|
@ -16,6 +16,7 @@ export const AGENT_MODEL_PROVIDERS = [
|
|||
'moonshotai',
|
||||
'alibaba',
|
||||
'minimax',
|
||||
'ollama',
|
||||
] as const;
|
||||
|
||||
/** Canonical `provider/model-name` validation shared by API schemas and editor parsing. */
|
||||
|
|
@ -54,6 +55,7 @@ export const AGENT_MODEL_PROVIDER_CREDENTIAL_TYPES = {
|
|||
moonshotai: ['moonshotApi'],
|
||||
alibaba: ['alibabaCloudApi'],
|
||||
minimax: ['minimaxApi'],
|
||||
ollama: ['ollamaApi'],
|
||||
} as const satisfies Record<AgentModelProvider, readonly [string, ...string[]]>;
|
||||
|
||||
/** Credential types for a provider prefix, or `[]` when it is not a model provider. */
|
||||
|
|
|
|||
|
|
@ -218,6 +218,33 @@ describe('BuilderModelLiveLookupService', () => {
|
|||
expect(result).toEqual({ status: 'unavailable', policy: 'endpoint-only', error });
|
||||
});
|
||||
|
||||
it('returns endpoint-only models for ollama and maps baseUrl', async () => {
|
||||
const { service, credentialsService, credentialsHelper } = makeService();
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
usable('cred-1', 'ollamaApi'),
|
||||
);
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
apiKey: '',
|
||||
});
|
||||
listModelsForProvider.mockResolvedValue([{ id: 'llama3.1', name: 'llama3.1' }]);
|
||||
|
||||
const result = await service.lookup(user, projectId, 'cred-1', 'ollamaApi', 'ollama');
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'success',
|
||||
policy: 'endpoint-only',
|
||||
models: [{ name: 'llama3.1', value: 'llama3.1' }],
|
||||
});
|
||||
expect(listModelsForProvider).toHaveBeenCalledWith(
|
||||
'ollama',
|
||||
expect.objectContaining({
|
||||
apiKey: '',
|
||||
baseURL: 'http://localhost:11434',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves endpoint-only policy when custom OpenAI discovery is empty', async () => {
|
||||
const { service, credentialsService, credentialsHelper } = makeService();
|
||||
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -169,9 +169,11 @@ export class BuilderModelLiveLookupService {
|
|||
const apiKey = typeof mapped.apiKey === 'string' ? mapped.apiKey : '';
|
||||
const baseURL =
|
||||
typeof mapped.baseURL === 'string' && mapped.baseURL ? mapped.baseURL : undefined;
|
||||
const policy =
|
||||
policyOverride ??
|
||||
(provider === 'openai' && isOpenAiCustomEndpoint(baseURL) ? 'endpoint-only' : 'curated');
|
||||
// Ollama serves its own models, so the user's server is authoritative the
|
||||
// same way a custom OpenAI endpoint is: models.dev is never read.
|
||||
const isEndpointOnlyProvider =
|
||||
(provider === 'openai' && isOpenAiCustomEndpoint(baseURL)) || provider === 'ollama';
|
||||
const policy = policyOverride ?? (isEndpointOnlyProvider ? 'endpoint-only' : 'curated');
|
||||
const headers = this.getOpenAiHeaders(provider, credentialData);
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,29 @@ describe('mapCredentialForProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('ollama', () => {
|
||||
it('maps the credential baseUrl onto baseURL', () => {
|
||||
expect(
|
||||
mapCredentialForProvider('ollama', {
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'http://localhost:11434',
|
||||
}),
|
||||
).toEqual({ apiKey: 'test-key', baseURL: 'http://localhost:11434' });
|
||||
});
|
||||
|
||||
it('passes through when the optional apiKey is unset', () => {
|
||||
expect(mapCredentialForProvider('ollama', { baseUrl: 'http://srv:11434' })).toEqual({
|
||||
apiKey: undefined,
|
||||
baseURL: 'http://srv:11434',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a supported agent provider', () => {
|
||||
expect(isSupportedAgentProvider('ollama')).toBe(true);
|
||||
expect(SUPPORTED_AGENT_PROVIDERS).toContain('ollama');
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an unmapped provider through unchanged', () => {
|
||||
const raw = { apiKey: 'key', url: 'https://example.com', someOtherField: 'kept' };
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ const PROVIDER_CREDENTIAL_MAPPERS: Record<string, CredMapper> = {
|
|||
minimax: (c) => ({ apiKey: c.apiKey, baseURL: c.url }),
|
||||
// AlibabaCloudApi.credentials.ts → apiKey, url (hidden, region-derived bare host)
|
||||
alibaba: (c) => ({ apiKey: c.apiKey, baseURL: c.url }),
|
||||
// OllamaApi.credentials.ts → apiKey (optional), baseUrl (the user's Ollama server)
|
||||
ollama: (c) => ({ apiKey: c.apiKey, baseURL: c.baseUrl }),
|
||||
|
||||
// AzureOpenAiApi.credentials.ts → apiKey, resourceName, apiVersion, endpoint, foundryEndpoint, endpointType
|
||||
// AzureEntraCognitiveServicesOAuth2Api.credentials.ts → resourceName, apiVersion, endpoint, foundryEndpoint, endpointType
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export const AGENT_MODEL_PROVIDER_DEFINITIONS = {
|
|||
moonshotai: { displayName: 'Moonshot' },
|
||||
alibaba: { displayName: 'Qwen Cloud' },
|
||||
minimax: { displayName: 'MiniMax' },
|
||||
ollama: { displayName: 'Ollama' },
|
||||
} satisfies Record<
|
||||
AgentModelProvider,
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user