Skip to content

Commit 39ffa9b

Browse files
ivicacclaude
andcommitted
1570 client - Add Ollama Base URL field to AI Providers settings
- AiProviderForm: for Ollama, render an optional Base URL field (blank defaults to http://localhost:11434) and make the API key optional; other providers are unchanged. Pass the provider object so the form can distinguish Ollama. - AiProviderList: treat Ollama as configured without an API key, and show its Base URL (or the localhost default) instead of an API key. - Regenerate the AI provider REST middleware for the new url field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c19176d commit 39ffa9b

7 files changed

Lines changed: 129 additions & 34 deletions

File tree

client/src/ee/pages/settings/platform/ai-providers/components/AiProviderForm.tsx

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Button from '@/components/Button/Button';
22
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage} from '@/components/ui/form';
33
import {Input} from '@/components/ui/input';
4+
import {AiProvider} from '@/ee/shared/middleware/platform/configuration';
45
import {useUpdateAiProviderMutation} from '@/ee/shared/mutations/platform/aiProvider.mutations';
56
import {AiProviderKeys} from '@/ee/shared/queries/platform/aiProviders.queries';
67
import {WorkflowNodeOptionKeys} from '@/shared/queries/platform/workflowNodeOptions.queries';
@@ -9,26 +10,28 @@ import {useQueryClient} from '@tanstack/react-query';
910
import {useForm} from 'react-hook-form';
1011
import {z} from 'zod';
1112

12-
const formSchema = z.object({
13-
apiKey: z.string().min(1, {
14-
message: 'API Key is required.',
15-
}),
16-
});
17-
1813
const AiProviderForm = ({
14+
aiProvider,
1915
environment,
20-
id,
2116
onClose,
2217
showCancel = false,
2318
}: {
19+
aiProvider: AiProvider;
2420
environment: number;
25-
id: number;
2621
onClose: () => void;
2722
showCancel: boolean;
2823
}) => {
24+
const isOllama = aiProvider.name?.toLowerCase() === 'ollama';
25+
26+
const formSchema = z.object({
27+
apiKey: isOllama ? z.string().optional() : z.string().min(1, {message: 'API Key is required.'}),
28+
url: z.string().optional(),
29+
});
30+
2931
const form = useForm<z.infer<typeof formSchema>>({
3032
defaultValues: {
3133
apiKey: '',
34+
url: aiProvider.url ?? '',
3235
},
3336
resolver: zodResolver(formSchema),
3437
});
@@ -51,33 +54,53 @@ const AiProviderForm = ({
5154
function handleSubmit(values: z.infer<typeof formSchema>) {
5255
updateAiProviderMutation.mutate({
5356
environment,
54-
id,
55-
updateAiProviderRequest: {
56-
apiKey: values.apiKey,
57-
},
57+
id: aiProvider.id!,
58+
updateAiProviderRequest: isOllama ? {url: values.url} : {apiKey: values.apiKey},
5859
});
5960
}
6061

6162
return (
6263
<Form {...form}>
6364
<form className="space-y-4" onSubmit={form.handleSubmit(handleSubmit)}>
64-
<FormField
65-
control={form.control}
66-
name="apiKey"
67-
render={({field}) => (
68-
<FormItem>
69-
<FormLabel>API Key</FormLabel>
65+
{isOllama ? (
66+
<FormField
67+
control={form.control}
68+
name="url"
69+
render={({field}) => (
70+
<FormItem>
71+
<FormLabel>Base URL</FormLabel>
7072

71-
<FormControl>
72-
<Input placeholder="API Key" {...field} />
73-
</FormControl>
73+
<FormControl>
74+
<Input placeholder="http://localhost:11434" {...field} />
75+
</FormControl>
7476

75-
<FormDescription>This is your AI provider&apos;`s API key.</FormDescription>
77+
<FormDescription>
78+
The base URL of your Ollama server. Leave blank to use http://localhost:11434.
79+
</FormDescription>
7680

77-
<FormMessage />
78-
</FormItem>
79-
)}
80-
/>
81+
<FormMessage />
82+
</FormItem>
83+
)}
84+
/>
85+
) : (
86+
<FormField
87+
control={form.control}
88+
name="apiKey"
89+
render={({field}) => (
90+
<FormItem>
91+
<FormLabel>API Key</FormLabel>
92+
93+
<FormControl>
94+
<Input placeholder="API Key" {...field} />
95+
</FormControl>
96+
97+
<FormDescription>This is your AI provider&apos;`s API key.</FormDescription>
98+
99+
<FormMessage />
100+
</FormItem>
101+
)}
102+
/>
103+
)}
81104

82105
<div className="flex gap-1">
83106
{showCancel && (

client/src/ee/pages/settings/platform/ai-providers/components/AiProviderList.test.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {AiProvider} from '@/ee/shared/middleware/platform/configuration';
22
import {createTestQueryClientWrapper} from '@/shared/util/test-utils';
3-
import {render, screen} from '@testing-library/react';
3+
import {fireEvent, render, screen} from '@testing-library/react';
44
import {ReactNode} from 'react';
55
import {describe, expect, it, vi} from 'vitest';
66

@@ -75,6 +75,45 @@ describe('AiProviderList', () => {
7575
expect(screen.getByText('Anthropic')).toBeInTheDocument();
7676
});
7777

78+
it('shows the Base URL row with the localhost default for Ollama when no URL is set', async () => {
79+
const ollamaProviders: AiProvider[] = [
80+
{
81+
enabled: false,
82+
icon: '/icons/ollama.svg',
83+
id: 3,
84+
name: 'Ollama',
85+
supportsEmbeddings: true,
86+
},
87+
];
88+
89+
renderWithProviders(<AiProviderList aiProviders={ollamaProviders} environment={1} />);
90+
91+
fireEvent.click(screen.getByText('Ollama'));
92+
93+
expect(await screen.findByText('Base URL:')).toBeInTheDocument();
94+
expect(screen.getByText('http://localhost:11434')).toBeInTheDocument();
95+
});
96+
97+
it('shows the configured Base URL for Ollama', async () => {
98+
const ollamaProviders: AiProvider[] = [
99+
{
100+
enabled: true,
101+
icon: '/icons/ollama.svg',
102+
id: 3,
103+
name: 'Ollama',
104+
supportsEmbeddings: true,
105+
url: 'http://remote-host:11434',
106+
},
107+
];
108+
109+
renderWithProviders(<AiProviderList aiProviders={ollamaProviders} environment={1} />);
110+
111+
fireEvent.click(screen.getByText('Ollama'));
112+
113+
expect(await screen.findByText('Base URL:')).toBeInTheDocument();
114+
expect(screen.getByText('http://remote-host:11434')).toBeInTheDocument();
115+
});
116+
78117
it('does not render the Embeddings badge when no provider supports embeddings', () => {
79118
const noEmbeddingProviders: AiProvider[] = [
80119
{

client/src/ee/pages/settings/platform/ai-providers/components/AiProviderList.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import InlineSVG from 'react-inlinesvg';
1313

1414
import './AiProviderList.css';
1515

16+
const isOllamaProvider = (aiProvider: AiProvider) => aiProvider.name?.toLowerCase() === 'ollama';
17+
18+
// Ollama runs locally and needs no API key, so it counts as configured; other providers require an API key.
19+
const isConfigured = (aiProvider: AiProvider) => isOllamaProvider(aiProvider) || !!aiProvider.apiKey;
20+
1621
const AiProviderList = ({aiProviders, environment}: {aiProviders: AiProvider[]; environment: number}) => {
1722
const [enabledItems, setEnabledItems] = useState<{[key: number]: boolean}>({});
1823
const [openItem, setOpenItem] = useState<string>();
@@ -39,11 +44,13 @@ const AiProviderList = ({aiProviders, environment}: {aiProviders: AiProvider[];
3944

4045
setOpenItem(undefined);
4146

42-
if (!aiProvider.apiKey && value) {
47+
const configured = isConfigured(aiProvider);
48+
49+
if (!configured && value) {
4350
setOpenItem(`item-${aiProvider.id}`);
4451
}
4552

46-
if (aiProvider.apiKey) {
53+
if (configured) {
4754
enableAiProviderMutation.mutate({
4855
enable: value,
4956
environment,
@@ -58,7 +65,7 @@ const AiProviderList = ({aiProviders, environment}: {aiProviders: AiProvider[];
5865

5966
aiProviders.forEach((aiProvider) => {
6067
enabledItems[aiProvider.id!] = !!aiProvider.enabled;
61-
showForm[aiProvider.id!] = !aiProvider.apiKey;
68+
showForm[aiProvider.id!] = !isConfigured(aiProvider);
6269
});
6370

6471
setEnabledItems(enabledItems);
@@ -112,21 +119,27 @@ const AiProviderList = ({aiProviders, environment}: {aiProviders: AiProvider[];
112119
<AccordionContent className="pb-3 pl-9">
113120
{showForm[aiProvider.id!] ? (
114121
<AiProviderForm
122+
aiProvider={aiProvider}
115123
environment={environment}
116-
id={aiProvider.id!}
117124
onClose={() =>
118125
setShowForm((prev) => ({
119126
...prev,
120127
[aiProvider.id!]: false,
121128
}))
122129
}
123-
showCancel={!!aiProvider.apiKey}
130+
showCancel={isConfigured(aiProvider)}
124131
/>
125132
) : (
126133
<div className="flex items-center gap-2">
127-
<span className="text-base text-muted-foreground">API Key: </span>
134+
<span className="text-base text-muted-foreground">
135+
{isOllamaProvider(aiProvider) ? 'Base URL: ' : 'API Key: '}
136+
</span>
128137

129-
<span className="text-base">{aiProvider.apiKey}</span>
138+
<span className="text-base">
139+
{isOllamaProvider(aiProvider)
140+
? aiProvider.url || 'http://localhost:11434'
141+
: aiProvider.apiKey}
142+
</span>
130143

131144
<Button
132145
onClick={() =>

client/src/ee/shared/middleware/platform/configuration/docs/AiProvider.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Name | Type
1111
`name` | string
1212
`icon` | string
1313
`apiKey` | string
14+
`url` | string
1415
`enabled` | boolean
1516
`supportsEmbeddings` | boolean
1617

@@ -25,6 +26,7 @@ const example = {
2526
"name": null,
2627
"icon": null,
2728
"apiKey": null,
29+
"url": null,
2830
"enabled": null,
2931
"supportsEmbeddings": null,
3032
} satisfies AiProvider

client/src/ee/shared/middleware/platform/configuration/docs/UpdateAiProviderRequest.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Name | Type
88
------------ | -------------
99
`apiKey` | string
10+
`url` | string
1011

1112
## Example
1213

@@ -16,6 +17,7 @@ import type { UpdateAiProviderRequest } from ''
1617
// TODO: Update the object below with actual values
1718
const example = {
1819
"apiKey": null,
20+
"url": null,
1921
} satisfies UpdateAiProviderRequest
2022

2123
console.log(example)

client/src/ee/shared/middleware/platform/configuration/models/AiProvider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export interface AiProvider {
4343
* @memberof AiProvider
4444
*/
4545
apiKey?: string;
46+
/**
47+
* The base URL of an AI provider (used by Ollama; blank defaults to localhost).
48+
* @type {string}
49+
* @memberof AiProvider
50+
*/
51+
url?: string;
4652
/**
4753
* The enabled status of an AI provider.
4854
* @type {boolean}
@@ -79,6 +85,7 @@ export function AiProviderFromJSONTyped(json: any, ignoreDiscriminator: boolean)
7985
'name': json['name'],
8086
'icon': json['icon'] == null ? undefined : json['icon'],
8187
'apiKey': json['apiKey'] == null ? undefined : json['apiKey'],
88+
'url': json['url'] == null ? undefined : json['url'],
8289
'enabled': json['enabled'] == null ? undefined : json['enabled'],
8390
'supportsEmbeddings': json['supportsEmbeddings'] == null ? undefined : json['supportsEmbeddings'],
8491
};
@@ -96,6 +103,7 @@ export function AiProviderToJSONTyped(value?: Omit<AiProvider, 'id'|'name'|'icon
96103
return {
97104

98105
'apiKey': value['apiKey'],
106+
'url': value['url'],
99107
'enabled': value['enabled'],
100108
};
101109
}

client/src/ee/shared/middleware/platform/configuration/models/UpdateAiProviderRequest.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export interface UpdateAiProviderRequest {
2525
* @memberof UpdateAiProviderRequest
2626
*/
2727
apiKey?: string;
28+
/**
29+
* The base URL of an AI provider (used by Ollama; blank defaults to localhost).
30+
* @type {string}
31+
* @memberof UpdateAiProviderRequest
32+
*/
33+
url?: string;
2834
}
2935

3036
/**
@@ -45,6 +51,7 @@ export function UpdateAiProviderRequestFromJSONTyped(json: any, ignoreDiscrimina
4551
return {
4652

4753
'apiKey': json['apiKey'] == null ? undefined : json['apiKey'],
54+
'url': json['url'] == null ? undefined : json['url'],
4855
};
4956
}
5057

@@ -60,6 +67,7 @@ export function UpdateAiProviderRequestToJSONTyped(value?: UpdateAiProviderReque
6067
return {
6168

6269
'apiKey': value['apiKey'],
70+
'url': value['url'],
6371
};
6472
}
6573

0 commit comments

Comments
 (0)