Skip to content
Merged
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 @@ -31,6 +31,7 @@ public class GenerateOptions {
// Connection-level configuration
private final String apiKey;
private final String baseUrl;
private final String endpointPath;
private final String modelName;
private final Boolean stream;

Expand Down Expand Up @@ -58,6 +59,7 @@ public class GenerateOptions {
private GenerateOptions(Builder builder) {
this.apiKey = builder.apiKey;
this.baseUrl = builder.baseUrl;
this.endpointPath = builder.endpointPath;
this.modelName = builder.modelName;
this.stream = builder.stream;
this.temperature = builder.temperature;
Expand Down Expand Up @@ -109,6 +111,21 @@ public String getBaseUrl() {
return baseUrl;
}

/**
* Gets the endpoint path for the API request.
*
* <p>This is the API endpoint path (e.g., "/v1/chat/completions").
* When null, the model's default endpoint path will be used.
*
* <p>This allows customization for OpenAI-compatible APIs that use different
* endpoint paths than the standard OpenAI API.
*
* @return the endpoint path, or null if not set
*/
public String getEndpointPath() {
return endpointPath;
}

/**
* Gets the model name to use for generation.
*
Expand Down Expand Up @@ -363,6 +380,8 @@ public static GenerateOptions mergeOptions(GenerateOptions primary, GenerateOpti
Builder builder = builder();
builder.apiKey(primary.apiKey != null ? primary.apiKey : fallback.apiKey);
builder.baseUrl(primary.baseUrl != null ? primary.baseUrl : fallback.baseUrl);
builder.endpointPath(
primary.endpointPath != null ? primary.endpointPath : fallback.endpointPath);
builder.modelName(primary.modelName != null ? primary.modelName : fallback.modelName);
builder.stream(primary.stream != null ? primary.stream : fallback.stream);
builder.temperature(
Expand Down Expand Up @@ -423,6 +442,7 @@ public static class Builder {
// Connection-level configuration
private String apiKey;
private String baseUrl;
private String endpointPath;
private String modelName;
private Boolean stream;

Expand Down Expand Up @@ -464,6 +484,21 @@ public Builder baseUrl(String baseUrl) {
return this;
}

/**
* Sets the endpoint path for the API request.
*
* <p>This allows customization for OpenAI-compatible APIs that use different
* endpoint paths than the standard OpenAI API (e.g., "/v4/chat/completions",
* "/api/v1/llm/chat", etc.). When null, the default endpoint path will be used.
*
* @param endpointPath the endpoint path (e.g., "/v1/chat/completions")
* @return this builder instance
*/
public Builder endpointPath(String endpointPath) {
this.endpointPath = endpointPath;
return this;
}

/**
* Sets the model name to use for generation.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ public static class Builder {
private Boolean stream;
private GenerateOptions defaultOptions;
private String baseUrl;
private String endpointPath;
private Formatter<OpenAIMessage, OpenAIResponse, OpenAIRequest> formatter;
private HttpTransport httpTransport;

Expand Down Expand Up @@ -257,6 +258,21 @@ public Builder baseUrl(String baseUrl) {
return this;
}

/**
* Sets a custom endpoint path for the API request.
*
* <p>This allows customization for OpenAI-compatible APIs that use different
* endpoint paths than the standard OpenAI API (e.g., "/v4/chat/completions",
* "/api/v1/llm/chat", etc.). When null, the default endpoint path will be used.
*
* @param endpointPath the endpoint path (e.g., "/v1/chat/completions")
* @return this builder instance
*/
public Builder endpointPath(String endpointPath) {
this.endpointPath = endpointPath;
return this;
}

/**
* Sets the message formatter to use.
*
Expand Down Expand Up @@ -297,13 +313,18 @@ public OpenAIChatModel build() {
Objects.requireNonNull(modelName, "modelName must be set");

// Build options from builder fields (these take precedence)
GenerateOptions builderOptions =
GenerateOptions.Builder optionsBuilder =
GenerateOptions.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.modelName(modelName)
.stream(stream)
.build();
.stream(stream);

if (endpointPath != null) {
optionsBuilder.endpointPath(endpointPath);
}

GenerateOptions builderOptions = optionsBuilder.build();

// Merge with defaultOptions (builder fields take precedence)
GenerateOptions mergedOptions =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ public OpenAIResponse call(
String effectiveBaseUrl = getEffectiveBaseUrl(baseUrl);
String effectiveApiKey = getEffectiveApiKey(apiKey);

// Allow options to override apiKey and baseUrl
// Allow options to override apiKey, baseUrl, and endpointPath
if (options != null) {
if (options.getApiKey() != null) {
effectiveApiKey = options.getApiKey();
Expand All @@ -265,7 +265,13 @@ public OpenAIResponse call(
}
}

String apiUrl = buildApiUrl(effectiveBaseUrl, CHAT_COMPLETIONS_ENDPOINT);
// Get endpoint path from options or use default
String endpointPath = CHAT_COMPLETIONS_ENDPOINT;
if (options != null && options.getEndpointPath() != null) {
endpointPath = options.getEndpointPath();
}

String apiUrl = buildApiUrl(effectiveBaseUrl, endpointPath);
String url = buildUrl(apiUrl, options);

try {
Expand Down Expand Up @@ -366,7 +372,7 @@ public Flux<OpenAIResponse> stream(
String effectiveBaseUrl = getEffectiveBaseUrl(baseUrl);
String effectiveApiKey = getEffectiveApiKey(apiKey);

// Allow options to override apiKey and baseUrl
// Allow options to override apiKey, baseUrl, and endpointPath
if (options != null) {
if (options.getApiKey() != null) {
effectiveApiKey = options.getApiKey();
Expand All @@ -376,7 +382,13 @@ public Flux<OpenAIResponse> stream(
}
}

String apiUrl = buildApiUrl(effectiveBaseUrl, CHAT_COMPLETIONS_ENDPOINT);
// Get endpoint path from options or use default
String endpointPath = CHAT_COMPLETIONS_ENDPOINT;
if (options != null && options.getEndpointPath() != null) {
endpointPath = options.getEndpointPath();
}

String apiUrl = buildApiUrl(effectiveBaseUrl, endpointPath);
String url = buildUrl(apiUrl, options);

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,89 @@ void testSetAdditionalQueryParamsMap() {
assertEquals(2, options.getAdditionalQueryParams().size());
assertEquals("v1", options.getAdditionalQueryParams().get("q1"));
}

@Test
@DisplayName("Should build GenerateOptions with endpoint path")
void testBuilderWithEndpointPath() {
GenerateOptions options =
GenerateOptions.builder().endpointPath("/v4/chat/completions").build();

assertNotNull(options);
assertEquals("/v4/chat/completions", options.getEndpointPath());
}

@Test
@DisplayName("Should build GenerateOptions with connection-level configuration")
void testBuilderWithConnectionLevelConfig() {
GenerateOptions options =
GenerateOptions.builder()
.apiKey("test-api-key")
.baseUrl("https://custom.api.com")
.endpointPath("/custom/path")
.modelName("custom-model")
.stream(true)
.build();

assertNotNull(options);
assertEquals("test-api-key", options.getApiKey());
assertEquals("https://custom.api.com", options.getBaseUrl());
assertEquals("/custom/path", options.getEndpointPath());
assertEquals("custom-model", options.getModelName());
assertEquals(Boolean.TRUE, options.getStream());
}

@Test
@DisplayName("Should merge endpoint path correctly")
void testMergeOptionsWithEndpointPath() {
GenerateOptions primary =
GenerateOptions.builder()
.temperature(0.8)
.endpointPath("/v4/chat/completions")
.build();

GenerateOptions fallback =
GenerateOptions.builder()
.temperature(0.5)
.endpointPath("/v1/chat/completions")
.build();

GenerateOptions merged = GenerateOptions.mergeOptions(primary, fallback);

assertNotNull(merged);
assertEquals(0.8, merged.getTemperature());
assertEquals("/v4/chat/completions", merged.getEndpointPath());
}

@Test
@DisplayName("Should use fallback endpoint path when primary is null")
void testMergeOptionsWithNullPrimaryEndpointPath() {
GenerateOptions primary = GenerateOptions.builder().temperature(0.8).build();

GenerateOptions fallback =
GenerateOptions.builder().endpointPath("/v1/chat/completions").build();

GenerateOptions merged = GenerateOptions.mergeOptions(primary, fallback);

assertNotNull(merged);
assertEquals(0.8, merged.getTemperature());
assertEquals("/v1/chat/completions", merged.getEndpointPath());
}

@Test
@DisplayName("Should have null endpoint path when not set")
void testNullEndpointPathWhenNotSet() {
GenerateOptions options = GenerateOptions.builder().temperature(0.5).build();

assertNotNull(options);
assertNull(options.getEndpointPath());
}

@Test
@DisplayName("Should handle null endpoint path explicitly")
void testExplicitNullEndpointPath() {
GenerateOptions options = GenerateOptions.builder().endpointPath(null).build();

assertNotNull(options);
assertNull(options.getEndpointPath());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,112 @@ void testApplyOptions() throws Exception {
void testGetModelName() {
assertEquals("gpt-4", model.getModelName());
}

@Test
@DisplayName("Should build model with custom endpoint path")
void testBuildModelWithEndpointPath() throws Exception {
String responseJson =
"""
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652280,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Response"
},
"finish_reason": "stop"
}]
}
""";

mockServer.enqueue(
new MockResponse()
.setBody(responseJson)
.setHeader("Content-Type", "application/json"));

// Build model with custom endpoint path
OpenAIChatModel customPathModel =
OpenAIChatModel.builder().apiKey("test-api-key").modelName("gpt-4").stream(false)
.baseUrl(mockServer.url("/").toString().replaceAll("/$", ""))
.endpointPath("/v4/chat/completions")
.formatter(new OpenAIChatFormatter())
.httpTransport(transport)
.build();

List<Msg> messages =
List.of(
Msg.builder()
.role(MsgRole.USER)
.content(List.of(TextBlock.builder().text("Hello").build()))
.build());

StepVerifier.create(customPathModel.stream(messages, null, null))
.assertNext(response -> assertNotNull(response))
.verifyComplete();

// Verify request uses custom endpoint path
RecordedRequest request = mockServer.takeRequest(1, TimeUnit.SECONDS);
assertNotNull(request);
assertTrue(
request.getPath().contains("/v4/chat/completions"),
"Path should contain custom endpoint path: " + request.getPath());
}

@Test
@DisplayName("Should build model with default endpoint path when not specified")
void testBuildModelWithDefaultEndpointPath() throws Exception {
String responseJson =
"""
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652280,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "Response"
},
"finish_reason": "stop"
}]
}
""";

mockServer.enqueue(
new MockResponse()
.setBody(responseJson)
.setHeader("Content-Type", "application/json"));

// Build model without endpoint path - should use default
OpenAIChatModel defaultPathModel =
OpenAIChatModel.builder().apiKey("test-api-key").modelName("gpt-4").stream(false)
.baseUrl(mockServer.url("/").toString().replaceAll("/$", ""))
.formatter(new OpenAIChatFormatter())
.httpTransport(transport)
.build();

List<Msg> messages =
List.of(
Msg.builder()
.role(MsgRole.USER)
.content(List.of(TextBlock.builder().text("Hello").build()))
.build());

StepVerifier.create(defaultPathModel.stream(messages, null, null))
.assertNext(response -> assertNotNull(response))
.verifyComplete();

// Verify request uses default endpoint path
RecordedRequest request = mockServer.takeRequest(1, TimeUnit.SECONDS);
assertNotNull(request);
assertTrue(
request.getPath().endsWith("/chat/completions")
|| request.getPath().contains("/v1/chat/completions"),
"Path should contain default endpoint path: " + request.getPath());
}
}
Loading
Loading