Skip to content

Commit c19176d

Browse files
ivicacclaude
andcommitted
1570 Support a configurable Ollama base URL in the AI Providers catalog
Ollama chat and embedding models built from the AI Providers catalog only received an API key, so a custom Ollama server URL was ignored and the client always hit the localhost default. - ApplicationProperties: add bytechef.ai.provider.ollama.url as the config-level fallback for both chat and embedding. - AiProviderFacade: store an optional url alongside the api key in the per- environment property map; resolve it (catalog -> config fallback); expose it on AiProviderDTO and AiDefaultModelWithApiKeyDTO; new getUrl / resolveUrl / getConfigUrl. Ollama needs no API key, so resolveWithApiKey no longer rejects a blank key for it. - Catalog factories: createChatModel/createEmbeddingModel now thread a url into the connection parameters (OllamaConstants.URL) when present; callers and their delegate cache keys include the url. - OllamaChatAction.CHAT_MODEL: read the response format optionally instead of requiring it, so the model can be built outside the ask action (e.g. the catalog) without a NullPointerException. - REST: add url to the UpdateAiProviderRequest and AiProvider OpenAPI schemas (response url is not obfuscated) and regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da8aa26 commit c19176d

23 files changed

Lines changed: 309 additions & 51 deletions

File tree

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/main/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatClientResolverImpl.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,14 @@ public CatalogChatClientResolverImpl(
5757

5858
String apiKey = aiProviderFacade.getApiKey(provider.getKey(), environment);
5959

60-
if (apiKey == null || apiKey.isBlank()) {
60+
// Ollama runs locally and needs no API key, so a blank key is valid for it; all other providers require one.
61+
if ((apiKey == null || apiKey.isBlank()) && provider != Provider.OLLAMA) {
6162
return null;
6263
}
6364

64-
ChatModel chatModel = catalogChatModelFactory.createChatModel(provider, model, apiKey);
65+
String url = aiProviderFacade.getUrl(provider.getKey(), environment);
66+
67+
ChatModel chatModel = catalogChatModelFactory.createChatModel(provider, model, apiKey, url);
6568

6669
if (chatModel == null) {
6770
return null;

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/main/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatModel.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,11 @@ private ChatModel resolveDelegate() {
7979
Provider provider = defaultModel.provider();
8080
String model = defaultModel.model();
8181
String apiKey = defaultModel.apiKey();
82+
String url = defaultModel.url();
8283

83-
String cacheKey = ordinal + ":" + provider.getKey() + ":" + model + ":" + apiKey;
84+
String cacheKey = ordinal + ":" + provider.getKey() + ":" + model + ":" + apiKey + ":" + url;
8485

85-
return delegateCache.get(cacheKey, key -> catalogChatModelFactory.createChatModel(provider, model, apiKey));
86+
return delegateCache.get(cacheKey,
87+
key -> catalogChatModelFactory.createChatModel(provider, model, apiKey, url));
8688
}
8789
}

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/main/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatModelFactory.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.bytechef.ee.platform.ai.agent.catalog;
99

1010
import static com.bytechef.component.ai.llm.constant.LLMConstants.MODEL;
11+
import static com.bytechef.component.ai.llm.ollama.constant.OllamaConstants.URL;
1112
import static com.bytechef.component.definition.Authorization.TOKEN;
1213

1314
import com.bytechef.component.ai.llm.anthropic.action.AnthropicChatAction;
@@ -23,6 +24,7 @@
2324
import com.bytechef.platform.ai.llm.Provider;
2425
import com.bytechef.platform.annotation.ConditionalOnEEVersion;
2526
import com.bytechef.platform.component.definition.ParametersFactory;
27+
import java.util.HashMap;
2628
import java.util.Map;
2729
import org.jspecify.annotations.Nullable;
2830
import org.springframework.ai.chat.model.ChatModel;
@@ -40,19 +42,35 @@
4042
@ConditionalOnEEVersion
4143
public class CatalogChatModelFactory {
4244

43-
public @Nullable ChatModel createChatModel(Provider provider, String model, String apiKey) {
45+
public @Nullable ChatModel createChatModel(
46+
Provider provider, String model, @Nullable String apiKey, @Nullable String url) {
47+
4448
com.bytechef.component.ai.llm.ChatModel chatModelFactory = resolveFactory(provider);
4549

4650
if (chatModelFactory == null) {
4751
return null;
4852
}
4953

5054
Parameters inputParameters = ParametersFactory.create(Map.of(MODEL, model));
51-
Parameters connectionParameters = ParametersFactory.create(Map.of(TOKEN, apiKey));
55+
Parameters connectionParameters = ParametersFactory.create(createConnectionParameters(apiKey, url));
5256

5357
return chatModelFactory.createChatModel(inputParameters, connectionParameters, false);
5458
}
5559

60+
private static Map<String, Object> createConnectionParameters(@Nullable String apiKey, @Nullable String url) {
61+
Map<String, Object> connectionParameters = new HashMap<>();
62+
63+
if (apiKey != null) {
64+
connectionParameters.put(TOKEN, apiKey);
65+
}
66+
67+
if (url != null && !url.isBlank()) {
68+
connectionParameters.put(URL, url);
69+
}
70+
71+
return connectionParameters;
72+
}
73+
5674
private static com.bytechef.component.ai.llm.@Nullable ChatModel resolveFactory(Provider provider) {
5775
return switch (provider) {
5876
case ANTHROPIC -> AnthropicChatAction.CHAT_MODEL;

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/main/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogEmbeddingModel.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ private EmbeddingModel resolveDelegate() {
112112
Provider provider = defaultModel.provider();
113113
String model = defaultModel.model();
114114
String apiKey = defaultModel.apiKey();
115+
String url = defaultModel.url();
115116

116117
return delegateCache.get(
117-
ordinal + ":" + provider.getKey() + ":" + model + ":" + apiKey,
118-
ignoredKey -> catalogEmbeddingModelFactory.createEmbeddingModel(provider, model, apiKey));
118+
ordinal + ":" + provider.getKey() + ":" + model + ":" + apiKey + ":" + url,
119+
ignoredKey -> catalogEmbeddingModelFactory.createEmbeddingModel(provider, model, apiKey, url));
119120
}
120121
}

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/main/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogEmbeddingModelFactory.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.bytechef.ee.platform.ai.agent.catalog;
99

1010
import static com.bytechef.component.ai.llm.constant.LLMConstants.MODEL;
11+
import static com.bytechef.component.ai.llm.ollama.constant.OllamaConstants.URL;
1112
import static com.bytechef.component.definition.Authorization.TOKEN;
1213

1314
import com.bytechef.component.ai.llm.ollama.cluster.OllamaEmbedding;
@@ -17,6 +18,7 @@
1718
import com.bytechef.platform.annotation.ConditionalOnEEVersion;
1819
import com.bytechef.platform.component.definition.ParametersFactory;
1920
import com.bytechef.platform.component.definition.ai.vectorstore.EmbeddingFunction;
21+
import java.util.HashMap;
2022
import java.util.Map;
2123
import org.jspecify.annotations.Nullable;
2224
import org.springframework.ai.embedding.EmbeddingModel;
@@ -35,19 +37,35 @@
3537
@ConditionalOnEEVersion
3638
public class CatalogEmbeddingModelFactory {
3739

38-
public @Nullable EmbeddingModel createEmbeddingModel(Provider provider, String model, String apiKey) {
40+
public @Nullable EmbeddingModel createEmbeddingModel(
41+
Provider provider, String model, @Nullable String apiKey, @Nullable String url) {
42+
3943
EmbeddingFunction embeddingFunction = resolveFactory(provider);
4044

4145
if (embeddingFunction == null) {
4246
return null;
4347
}
4448

4549
Parameters inputParameters = ParametersFactory.create(Map.of(MODEL, model));
46-
Parameters connectionParameters = ParametersFactory.create(Map.of(TOKEN, apiKey));
50+
Parameters connectionParameters = ParametersFactory.create(createConnectionParameters(apiKey, url));
4751

4852
return embeddingFunction.apply(inputParameters, connectionParameters);
4953
}
5054

55+
private static Map<String, Object> createConnectionParameters(@Nullable String apiKey, @Nullable String url) {
56+
Map<String, Object> connectionParameters = new HashMap<>();
57+
58+
if (apiKey != null) {
59+
connectionParameters.put(TOKEN, apiKey);
60+
}
61+
62+
if (url != null && !url.isBlank()) {
63+
connectionParameters.put(URL, url);
64+
}
65+
66+
return connectionParameters;
67+
}
68+
5169
private static @Nullable EmbeddingFunction resolveFactory(Provider provider) {
5270
return switch (provider) {
5371
case OLLAMA -> OllamaEmbedding.EMBEDDING_MODEL;

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/test/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatClientResolverTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ void testResolveReturnsNullWhenNoApiKey() {
5252
@Test
5353
void testResolveBuildsClientWhenApiKeyResolves() {
5454
when(aiProviderFacade.getApiKey(OPEN_AI_KEY, 1)).thenReturn("sk-test");
55-
when(catalogChatModelFactory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-test"))
55+
when(catalogChatModelFactory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-test", null))
5656
.thenReturn(mock(org.springframework.ai.chat.model.ChatModel.class));
5757

5858
assertThat(resolver.resolve(OPEN_AI_KEY, "gpt-4o", 1)).isNotNull();
@@ -61,7 +61,7 @@ void testResolveBuildsClientWhenApiKeyResolves() {
6161
@Test
6262
void testResolveBuildsClientWhenApiKeyIsConfigFallback() {
6363
when(aiProviderFacade.getApiKey(OPEN_AI_KEY, 1)).thenReturn("sk-config");
64-
when(catalogChatModelFactory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-config"))
64+
when(catalogChatModelFactory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-config", null))
6565
.thenReturn(mock(org.springframework.ai.chat.model.ChatModel.class));
6666

6767
assertThat(resolver.resolve(OPEN_AI_KEY, "gpt-4o", 1)).isNotNull();

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/test/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatModelFactoryTest.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class CatalogChatModelFactoryTest {
2727
@Test
2828
void testCreateChatModelForOpenAiReturnsModel() {
2929
org.springframework.ai.chat.model.ChatModel chatModel =
30-
factory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-test-key");
30+
factory.createChatModel(Provider.OPEN_AI, "gpt-4o", "sk-test-key", null);
3131

3232
assertThat(chatModel).isNotNull();
3333
}
@@ -36,8 +36,17 @@ void testCreateChatModelForOpenAiReturnsModel() {
3636
void testCreateChatModelForCatalogOnlyConnectionProviderReturnsNull() {
3737
// Azure needs a deployment endpoint the catalog doesn't store; v1 returns null -> caller falls back.
3838
org.springframework.ai.chat.model.ChatModel chatModel =
39-
factory.createChatModel(Provider.AZURE_OPEN_AI, "gpt-4o", "sk-test-key");
39+
factory.createChatModel(Provider.AZURE_OPEN_AI, "gpt-4o", "sk-test-key", null);
4040

4141
assertThat(chatModel).isNull();
4242
}
43+
44+
@Test
45+
void testCreateChatModelForOllamaWithCustomUrlReturnsModel() {
46+
// Ollama needs no API key and accepts a custom base URL threaded through the connection parameters.
47+
org.springframework.ai.chat.model.ChatModel chatModel =
48+
factory.createChatModel(Provider.OLLAMA, "llama3", null, "http://remote-host:11434");
49+
50+
assertThat(chatModel).isNotNull();
51+
}
4352
}

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/test/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogChatModelTest.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class CatalogChatModelTest {
3434

3535
private static final String ANTHROPIC_MODEL = "claude-sonnet-4-5";
3636
private static final AiDefaultModelWithApiKeyDTO ANTHROPIC_DEFAULT_MODEL =
37-
new AiDefaultModelWithApiKeyDTO(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic");
37+
new AiDefaultModelWithApiKeyDTO(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic", null);
3838

3939
private final AiProviderFacade aiProviderFacade = mock(AiProviderFacade.class);
4040
private final CatalogChatModelFactory catalogChatModelFactory = mock(CatalogChatModelFactory.class);
@@ -56,7 +56,7 @@ void testDelegatesCallToDefaultProviderWhenModelResolved() {
5656
when(delegate.call(prompt)).thenReturn(response);
5757
when(aiProviderFacade.getAiDefaultChatModelApiKey(Environment.PRODUCTION.ordinal()))
5858
.thenReturn(ANTHROPIC_DEFAULT_MODEL);
59-
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic"))
59+
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic", null))
6060
.thenReturn(delegate);
6161

6262
assertThat(catalogChatModel.call(prompt)).isSameAs(response);
@@ -84,7 +84,7 @@ void testReadsEnvironmentFromContext() {
8484
when(delegate.call(prompt)).thenReturn(mock(ChatResponse.class));
8585
when(aiProviderFacade.getAiDefaultChatModelApiKey(Environment.STAGING.ordinal()))
8686
.thenReturn(ANTHROPIC_DEFAULT_MODEL);
87-
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic"))
87+
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic", null))
8888
.thenReturn(delegate);
8989

9090
catalogChatModel.call(prompt);
@@ -100,13 +100,13 @@ void testCachesDelegatePerEnvironmentAndKey() {
100100
.thenReturn(mock(ChatResponse.class));
101101
when(aiProviderFacade.getAiDefaultChatModelApiKey(Environment.PRODUCTION.ordinal()))
102102
.thenReturn(ANTHROPIC_DEFAULT_MODEL);
103-
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic"))
103+
when(catalogChatModelFactory.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic", null))
104104
.thenReturn(delegate);
105105

106106
catalogChatModel.call(new Prompt("a"));
107107
catalogChatModel.call(new Prompt("b"));
108108

109109
verify(catalogChatModelFactory, times(1))
110-
.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic");
110+
.createChatModel(Provider.ANTHROPIC, ANTHROPIC_MODEL, "sk-anthropic", null);
111111
}
112112
}

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/test/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogEmbeddingModelFactoryTest.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,30 @@ class CatalogEmbeddingModelFactoryTest {
2828
@Test
2929
void testBuildsOpenAiEmbeddingModel() {
3030
EmbeddingModel embeddingModel = factory.createEmbeddingModel(
31-
Provider.OPEN_AI, "text-embedding-3-small", "sk-test");
31+
Provider.OPEN_AI, "text-embedding-3-small", "sk-test", null);
3232

3333
assertThat(embeddingModel).isNotNull();
3434
}
3535

3636
@Test
3737
void testReturnsNullForProviderWithoutEmbeddingSupport() {
38-
assertThat(factory.createEmbeddingModel(Provider.ANTHROPIC, "irrelevant", "sk-test")).isNull();
38+
assertThat(factory.createEmbeddingModel(Provider.ANTHROPIC, "irrelevant", "sk-test", null)).isNull();
39+
}
40+
41+
@Test
42+
void testBuildsOllamaEmbeddingModelWithCustomUrl() {
43+
// Ollama needs no API key and accepts a custom base URL threaded through the connection parameters.
44+
EmbeddingModel embeddingModel = factory.createEmbeddingModel(
45+
Provider.OLLAMA, "nomic-embed-text", null, "http://remote-host:11434");
46+
47+
assertThat(embeddingModel).isNotNull();
3948
}
4049

4150
@Test
4251
void testEmbeddingSupportMatchesProviderCapability() {
4352
for (Provider provider : Provider.values()) {
4453
EmbeddingModel embeddingModel = factory.createEmbeddingModel(
45-
provider, "text-embedding-3-small", "sk-test");
54+
provider, "text-embedding-3-small", "sk-test", null);
4655

4756
assertThat(embeddingModel != null)
4857
.as("Provider %s: factory builds an embedding model iff Provider.isEmbeddingSupported()", provider)

server/ee/libs/platform/platform-ai/platform-ai-agent/platform-ai-agent-service/src/test/java/com/bytechef/ee/platform/ai/agent/catalog/CatalogEmbeddingModelTest.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
class CatalogEmbeddingModelTest {
3333

3434
private static final AiDefaultModelWithApiKeyDTO OPEN_AI_DEFAULT_MODEL =
35-
new AiDefaultModelWithApiKeyDTO(Provider.OPEN_AI, "text-embedding-3-small", "sk-test");
35+
new AiDefaultModelWithApiKeyDTO(Provider.OPEN_AI, "text-embedding-3-small", "sk-test", null);
3636

3737
private final AiProviderFacade aiProviderFacade = mock(AiProviderFacade.class);
3838
private final CatalogEmbeddingModelFactory catalogEmbeddingModelFactory =
@@ -56,8 +56,9 @@ void testDelegatesEmbedWhenModelResolved() {
5656
});
5757
when(aiProviderFacade.getAiDefaultEmbeddingModelApiKey(Environment.PRODUCTION.ordinal()))
5858
.thenReturn(OPEN_AI_DEFAULT_MODEL);
59-
when(catalogEmbeddingModelFactory.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test"))
60-
.thenReturn(delegate);
59+
when(catalogEmbeddingModelFactory.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test",
60+
null))
61+
.thenReturn(delegate);
6162

6263
float[] result = catalogEmbeddingModel.embed(document);
6364

@@ -89,7 +90,7 @@ void testReadsEnvironmentFromContext() {
8990
when(aiProviderFacade.getAiDefaultEmbeddingModelApiKey(Environment.STAGING.ordinal()))
9091
.thenReturn(OPEN_AI_DEFAULT_MODEL);
9192
when(catalogEmbeddingModelFactory.createEmbeddingModel(
92-
Provider.OPEN_AI, "text-embedding-3-small", "sk-test"))
93+
Provider.OPEN_AI, "text-embedding-3-small", "sk-test", null))
9394
.thenReturn(delegate);
9495

9596
catalogEmbeddingModel.embed(document);
@@ -106,13 +107,14 @@ void testCachesDelegatePerEnvironmentAndKey() {
106107
});
107108
when(aiProviderFacade.getAiDefaultEmbeddingModelApiKey(Environment.PRODUCTION.ordinal()))
108109
.thenReturn(OPEN_AI_DEFAULT_MODEL);
109-
when(catalogEmbeddingModelFactory.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test"))
110-
.thenReturn(delegate);
110+
when(catalogEmbeddingModelFactory.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test",
111+
null))
112+
.thenReturn(delegate);
111113

112114
catalogEmbeddingModel.embed(new Document("a"));
113115
catalogEmbeddingModel.embed(new Document("b"));
114116

115117
verify(catalogEmbeddingModelFactory, times(1))
116-
.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test");
118+
.createEmbeddingModel(Provider.OPEN_AI, "text-embedding-3-small", "sk-test", null);
117119
}
118120
}

0 commit comments

Comments
 (0)