This commit is contained in:
Rene Turcios 2026-10-16 22:41:41 +05:30 committed by GitHub
commit 3826c2ed86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 208 additions and 6 deletions

View File

@ -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:/);
});

View File

@ -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 } =

View File

@ -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({

View File

@ -421,6 +421,7 @@ describe('model-discovery', () => {
'mistral',
'moonshotai',
'nvidia',
'ollama',
'openai',
'openrouter',
'vercel',

View File

@ -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');
});
});

View File

@ -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,

View File

@ -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));
};

View File

@ -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. */

View File

@ -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(

View File

@ -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 {

View File

@ -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' };

View File

@ -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

View File

@ -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,
{