diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java
index 2ee704514bc..cd57067138f 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java
@@ -14,15 +14,16 @@
* To add a new provider, create a class that implements {@link ModelProviderStrategy}
* and add an instance to {@link #STRATEGIES}. No other class needs to change.
*
- *
Supported providers: {@code openai}, {@code azure_openai}, {@code vertex_ai}
- *
Note: {@code vertex_ai} supports chat only; embeddings and image are not available via LangChain4J.
+ *
Supported providers: {@code openai}, {@code azure_openai}, {@code vertex_ai}, {@code openrouter}
+ *
Note: {@code vertex_ai} and {@code openrouter} support chat only; embeddings and image are not available via LangChain4J.
*/
public class LangChain4jModelFactory {
static final List STRATEGIES = List.of(
new OpenAiModelProviderStrategy(),
new AzureOpenAiModelProviderStrategy(),
- new VertexAiModelProviderStrategy()
+ new VertexAiModelProviderStrategy(),
+ new OpenRouterModelProviderStrategy()
);
private LangChain4jModelFactory() {}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java
new file mode 100644
index 00000000000..58d17f65660
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java
@@ -0,0 +1,90 @@
+package com.dotcms.ai.client.langchain4j;
+
+import com.dotmarketing.util.Logger;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.StreamingChatModel;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.model.image.ImageModel;
+import dev.langchain4j.model.openai.OpenAiChatModel;
+import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
+
+import java.time.Duration;
+
+/**
+ * {@link ModelProviderStrategy} implementation for OpenRouter.
+ *
+ * OpenRouter exposes an OpenAI-compatible API, so this strategy reuses the
+ * LangChain4J OpenAI model classes pointed at the OpenRouter base URL
+ * ({@value #DEFAULT_BASE_URL}). The {@code endpoint} config field overrides the
+ * base URL if set.
+ *
+ *
Model IDs use the OpenRouter namespaced form, e.g. {@code openai/gpt-4o},
+ * {@code anthropic/claude-sonnet-4}, {@code deepseek/deepseek-r1}.
+ *
+ *
Supports chat (streaming and non-streaming) only. OpenRouter does not offer
+ * embeddings or image-generation endpoints.
+ */
+class OpenRouterModelProviderStrategy implements ModelProviderStrategy {
+
+ static final String DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
+
+ @Override
+ public String providerName() {
+ return "openrouter";
+ }
+
+ @Override
+ public ChatModel buildChatModel(final ProviderConfig config, final String modelType) {
+ validate(config, modelType);
+ final OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder()
+ .apiKey(config.apiKey())
+ .modelName(config.model())
+ .baseUrl(baseUrl(config));
+ if (config.temperature() != null) builder.temperature(config.temperature());
+ if (config.maxTokens() != null) builder.maxTokens(config.maxTokens());
+ if (config.maxRetries() != null) builder.maxRetries(config.maxRetries());
+ if (config.timeout() != null) builder.timeout(Duration.ofSeconds(config.timeout()));
+ return builder.build();
+ }
+
+ @Override
+ public StreamingChatModel buildStreamingChatModel(final ProviderConfig config, final String modelType) {
+ validate(config, modelType);
+ final OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder()
+ .apiKey(config.apiKey())
+ .modelName(config.model())
+ .baseUrl(baseUrl(config));
+ if (config.maxRetries() != null) {
+ Logger.warn(OpenRouterModelProviderStrategy.class,
+ "maxRetries is not supported by the OpenRouter streaming chat model and will be ignored");
+ }
+ if (config.temperature() != null) builder.temperature(config.temperature());
+ if (config.maxTokens() != null) builder.maxTokens(config.maxTokens());
+ if (config.timeout() != null) builder.timeout(Duration.ofSeconds(config.timeout()));
+ return builder.build();
+ }
+
+ @Override
+ public EmbeddingModel buildEmbeddingModel(final ProviderConfig config, final String modelType) {
+ throw new UnsupportedOperationException(
+ "Embeddings are not supported by OpenRouter (no embeddings endpoint)");
+ }
+
+ @Override
+ public ImageModel buildImageModel(final ProviderConfig config, final String modelType) {
+ throw new UnsupportedOperationException(
+ "Image generation is not supported by OpenRouter via LangChain4J");
+ }
+
+ private void validate(final ProviderConfig config, final String modelType) {
+ ModelProviderStrategy.requireNonBlank(config.model(), "model", modelType);
+ ModelProviderStrategy.requireNonBlank(config.apiKey(), "apiKey", modelType);
+ }
+
+ private static String baseUrl(final ProviderConfig config) {
+ return config.endpoint() != null && !config.endpoint().isBlank()
+ ? config.endpoint()
+ : DEFAULT_BASE_URL;
+ }
+
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java
index ee9b416a686..7bfe2a322c8 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java
@@ -18,7 +18,7 @@
*
*
Common fields (all providers):
*
- * - {@code provider} – identifier: {@code openai}, {@code azure_openai}, {@code bedrock}, {@code vertex_ai}
+ * - {@code provider} – identifier: {@code openai}, {@code azure_openai}, {@code bedrock}, {@code vertex_ai}, {@code openrouter}
* - {@code model} – model name or ID
* - {@code maxTokens} – max output tokens
* - {@code temperature} – sampling temperature (0.0–2.0)
@@ -43,6 +43,13 @@
* - {@code secretAccessKey}
*
*
+ * OpenRouter (chat only — OpenRouter has no embeddings or image endpoints):
+ *
+ * - {@code apiKey} – OpenRouter API key
+ * - {@code model} – namespaced model ID, e.g. {@code openai/gpt-4o}, {@code anthropic/claude-sonnet-4}
+ * - {@code endpoint} – optional override of the default base URL ({@code https://openrouter.ai/api/v1})
+ *
+ *
* Google Vertex AI (chat only — embeddings and image not supported by this integration):
*
* - {@code projectId} – GCP project ID
diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactoryTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactoryTest.java
index 7516ed398ed..28d98218f8a 100644
--- a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactoryTest.java
+++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactoryTest.java
@@ -472,8 +472,114 @@ public void test_buildImageModel_azureOpenai_foundryEndpoint_missingModel_throws
assertThrows(IllegalArgumentException.class, () -> LangChain4jModelFactory.buildImageModel(config));
}
+ // ── OpenRouter ────────────────────────────────────────────────────────────
+
+ /**
+ * Given a valid OpenRouter config,
+ * When buildChatModel is called,
+ * Then a ChatModel is returned successfully.
+ */
+ @Test
+ public void test_buildChatModel_openRouter_returnsModel() {
+ final ChatModel model = LangChain4jModelFactory.buildChatModel(openRouterConfig("openai/gpt-4o"));
+ assertNotNull(model);
+ }
+
+ /**
+ * Given a valid OpenRouter config with optional parameters,
+ * When buildStreamingChatModel is called,
+ * Then a StreamingChatModel is returned successfully.
+ */
+ @Test
+ public void test_buildStreamingChatModel_openRouter_returnsModel() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openrouter")
+ .model("anthropic/claude-sonnet-4")
+ .apiKey("test-key")
+ .temperature(0.7)
+ .maxTokens(2048)
+ .timeout(60)
+ .build();
+ assertNotNull(LangChain4jModelFactory.buildStreamingChatModel(config));
+ }
+
+ /**
+ * Given an OpenRouter config with a custom endpoint override,
+ * When buildChatModel is called,
+ * Then a ChatModel is returned successfully.
+ */
+ @Test
+ public void test_buildChatModel_openRouter_customEndpoint_returnsModel() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openrouter")
+ .model("openai/gpt-4o")
+ .apiKey("test-key")
+ .endpoint("https://my-proxy.example.com/api/v1")
+ .build();
+ assertNotNull(LangChain4jModelFactory.buildChatModel(config));
+ }
+
+ /**
+ * Given an OpenRouter config without an apiKey,
+ * When buildChatModel is called,
+ * Then an IllegalArgumentException is thrown.
+ */
+ @Test
+ public void test_buildChatModel_openRouter_missingApiKey_throws() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openrouter")
+ .model("openai/gpt-4o")
+ .build();
+ assertThrows(IllegalArgumentException.class, () -> LangChain4jModelFactory.buildChatModel(config));
+ }
+
+ /**
+ * Given an OpenRouter config without a model,
+ * When buildChatModel is called,
+ * Then an IllegalArgumentException is thrown.
+ */
+ @Test
+ public void test_buildChatModel_openRouter_missingModel_throws() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openrouter")
+ .apiKey("test-key")
+ .build();
+ assertThrows(IllegalArgumentException.class, () -> LangChain4jModelFactory.buildChatModel(config));
+ }
+
+ /**
+ * Given an OpenRouter config,
+ * When buildEmbeddingModel is called,
+ * Then an UnsupportedOperationException is thrown since OpenRouter has no embeddings endpoint.
+ */
+ @Test
+ public void test_buildEmbeddingModel_openRouter_throws() {
+ assertThrows(UnsupportedOperationException.class,
+ () -> LangChain4jModelFactory.buildEmbeddingModel(openRouterConfig("openai/text-embedding-3-small")));
+ }
+
+ /**
+ * Given an OpenRouter config,
+ * When buildImageModel is called,
+ * Then an UnsupportedOperationException is thrown since image generation is not supported.
+ */
+ @Test
+ public void test_buildImageModel_openRouter_throws() {
+ assertThrows(UnsupportedOperationException.class,
+ () -> LangChain4jModelFactory.buildImageModel(openRouterConfig("openai/dall-e-3")));
+ }
+
// ── Helpers ───────────────────────────────────────────────────────────────
+ private static ProviderConfig openRouterConfig(final String model) {
+ return ImmutableProviderConfig.builder()
+ .provider("openrouter")
+ .model(model)
+ .apiKey("test-key")
+ .build();
+ }
+
+
private static ProviderConfig openAiConfig(final String model) {
return ImmutableProviderConfig.builder()
.provider("openai")