Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Supported providers: {@code openai}, {@code azure_openai}, {@code vertex_ai}
* <p>Note: {@code vertex_ai} supports chat only; embeddings and image are not available via LangChain4J.
* <p>Supported providers: {@code openai}, {@code azure_openai}, {@code vertex_ai}, {@code openrouter}
* <p>Note: {@code vertex_ai} and {@code openrouter} support chat only; embeddings and image are not available via LangChain4J.
*/
public class LangChain4jModelFactory {

static final List<ModelProviderStrategy> STRATEGIES = List.of(
new OpenAiModelProviderStrategy(),
new AzureOpenAiModelProviderStrategy(),
new VertexAiModelProviderStrategy()
new VertexAiModelProviderStrategy(),
new OpenRouterModelProviderStrategy()
);

private LangChain4jModelFactory() {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>Model IDs use the OpenRouter namespaced form, e.g. {@code openai/gpt-4o},
* {@code anthropic/claude-sonnet-4}, {@code deepseek/deepseek-r1}.
*
* <p>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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*
* <p>Common fields (all providers):
* <ul>
* <li>{@code provider} – identifier: {@code openai}, {@code azure_openai}, {@code bedrock}, {@code vertex_ai}</li>
* <li>{@code provider} – identifier: {@code openai}, {@code azure_openai}, {@code bedrock}, {@code vertex_ai}, {@code openrouter}</li>
* <li>{@code model} – model name or ID</li>
* <li>{@code maxTokens} – max output tokens</li>
* <li>{@code temperature} – sampling temperature (0.0–2.0)</li>
Expand All @@ -43,6 +43,13 @@
* <li>{@code secretAccessKey}</li>
* </ul>
*
* <p>OpenRouter (chat only — OpenRouter has no embeddings or image endpoints):
* <ul>
* <li>{@code apiKey} – OpenRouter API key</li>
* <li>{@code model} – namespaced model ID, e.g. {@code openai/gpt-4o}, {@code anthropic/claude-sonnet-4}</li>
* <li>{@code endpoint} – optional override of the default base URL ({@code https://openrouter.ai/api/v1})</li>
* </ul>
*
* <p>Google Vertex AI (chat only — embeddings and image not supported by this integration):
* <ul>
* <li>{@code projectId} – GCP project ID</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading