Skip to content
Open
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 @@ -47,16 +47,22 @@ public class OpenAITextEmbedding implements EmbeddingModel {

private static final Logger log = LoggerFactory.getLogger(OpenAITextEmbedding.class);

/** Default dimension when not explicitly configured, per EmbeddingModel interface contract. */
private static final int DEFAULT_DIMENSIONS = 1024;

private final String apiKey;
private final String modelName;
private final int dimensions;
private final Integer dimensions;
private final ExecutionConfig defaultExecutionConfig;

private final String baseUrl;

/**
* Creates a new OpenAI text embedding model instance.
*
* <p>This constructor is kept for binary compatibility with existing compiled code.
* It delegates to {@link #OpenAITextEmbedding(String, String, Integer, ExecutionConfig, String)}.
*
* @param apiKey the API key for OpenAI authentication
* @param modelName the model name (e.g., "text-embedding-3-small")
* @param dimensions the dimension of embedding vectors
Expand All @@ -69,6 +75,32 @@ public OpenAITextEmbedding(
int dimensions,
ExecutionConfig defaultExecutionConfig,
String baseUrl) {
this(apiKey, modelName, Integer.valueOf(dimensions), defaultExecutionConfig, baseUrl);
}

/**
* Creates a new OpenAI text embedding model instance with optional dimensions.
*
* <p>When {@code dimensions} is null, the dimensions parameter will not be sent
* to the API, allowing open-source models that don't support matryoshka
* representation (e.g., BAAI/bge-large-zh-v1.5) to work correctly.
*
* @param apiKey the API key for OpenAI authentication
* @param modelName the model name (e.g., "text-embedding-3-small")
* @param dimensions the dimension of embedding vectors (null to omit from API request)
* @param defaultExecutionConfig default execution configuration for timeout and retry
* @param baseUrl custom base URL for OpenAI API (null for default)
* @throws IllegalArgumentException if dimensions is non-null and not positive
*/
public OpenAITextEmbedding(
String apiKey,
String modelName,
Integer dimensions,
ExecutionConfig defaultExecutionConfig,
String baseUrl) {
if (dimensions != null && dimensions <= 0) {
throw new IllegalArgumentException("dimensions must be positive, got: " + dimensions);
}
this.apiKey = apiKey;
this.modelName = modelName;
this.dimensions = dimensions;
Expand Down Expand Up @@ -132,15 +164,19 @@ public Mono<double[]> embed(ContentBlock block) {

OpenAIClient client = clientBuilder.build();

EmbeddingCreateParams createParams =
EmbeddingCreateParams.Builder paramsBuilder =
EmbeddingCreateParams.builder()
.model(modelName)
.dimensions(dimensions)
.encodingFormat(
EmbeddingCreateParams.EncodingFormat
.FLOAT)
.inputOfArrayOfStrings(List.of(text))
.build();
.inputOfArrayOfStrings(List.of(text));

if (dimensions != null) {
paramsBuilder.dimensions(dimensions);
}

EmbeddingCreateParams createParams = paramsBuilder.build();

log.debug(
"OpenAI embedding call: model={},"
Expand Down Expand Up @@ -181,8 +217,9 @@ public Mono<double[]> embed(ContentBlock block) {
EmbeddingUtils.convertFloatListToDoubleArray(
embeddingValues);

// Validate dimension
if (embeddingArray.length != dimensions) {
// Validate dimension only when explicitly configured
if (dimensions != null
&& embeddingArray.length != dimensions) {
log.warn(
"Embedding dimension mismatch: expected={},"
+ " actual={}",
Expand Down Expand Up @@ -223,9 +260,20 @@ public String getModelName() {
return modelName;
}

/**
* {@inheritDoc}
*
* <p>Returns the explicitly configured dimensions, or {@value #DEFAULT_DIMENSIONS} if {@code
* dimensions} was never configured (per the {@link EmbeddingModel#getDimensions()} contract,
* which documents 1024 as the default when unspecified). This fallback is a placeholder only:
* for open-source models where dimensions is intentionally left unset, the true embedding
* vector length returned by {@link #embed} may differ from this value. Callers needing the
* exact size should inspect the length of the array returned by {@link #embed}, not this
* getter.
*/
@Override
public int getDimensions() {
Comment thread
Asaduddin18 marked this conversation as resolved.
return dimensions;
return dimensions != null ? dimensions : DEFAULT_DIMENSIONS;
}

/**
Expand All @@ -234,7 +282,7 @@ public int getDimensions() {
public static class Builder {
private String apiKey;
private String modelName;
private int dimensions = 1536;
private Integer dimensions = 1536;
private ExecutionConfig defaultExecutionConfig;
private String baseUrl;

Expand All @@ -261,12 +309,21 @@ public Builder modelName(String modelName) {
}

/**
* Sets the dimension of embedding vectors.
* Sets the dimension of embedding vectors. Defaults to 1536.
*
* <p>When set to a positive value, the dimensions parameter will be included in the API
* request. This is only supported by OpenAI official embedding models (e.g.,
* text-embedding-3-small, text-embedding-3-large) that support matryoshka
* representation.
*
* <p>For open-source models (e.g., BAAI/bge-large-zh-v1.5) that do not support this
* parameter and will return a 400 error if it is sent, pass {@code null} to omit the
* parameter from the request entirely.
*
* @param dimensions the dimension
* @param dimensions the dimension, or null to omit the parameter from the API request
* @return this builder instance
*/
public Builder dimensions(int dimensions) {
public Builder dimensions(Integer dimensions) {
this.dimensions = dimensions;
return this;
}
Expand Down Expand Up @@ -311,7 +368,7 @@ public OpenAITextEmbedding build() {
throw new IllegalStateException(
"modelName is required and cannot be null or empty");
}
if (dimensions <= 0) {
if (dimensions != null && dimensions <= 0) {
throw new IllegalStateException("dimensions must be positive, got: " + dimensions);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,100 @@ void testEmptyEmbeddingListInResponse() {
.verify();
}
}

@Test
@DisplayName("Should successfully embed without dimensions parameter (open-source models)")
void testEmbedWithoutDimensions() {
// Explicitly omit dimensions - simulates open-source model usage
OpenAITextEmbedding modelWithoutDimensions =
OpenAITextEmbedding.builder()
.apiKey("mock_api_key")
.modelName("BAAI/bge-large-zh-v1.5")
.dimensions(null)
.executionConfig(ExecutionConfig.builder().maxAttempts(1).build())
.build();

// Prepare mock response
Embedding mockEmbedding = mock(Embedding.class);
when(mockEmbedding.embedding()).thenReturn(Arrays.asList(0.5f, 0.6f, 0.7f, 0.8f));

CreateEmbeddingResponse mockResponse = mock(CreateEmbeddingResponse.class);
when(mockResponse.data()).thenReturn(Arrays.asList(mockEmbedding));

try (MockedStatic<OpenAIOkHttpClient> mockedClient =
Mockito.mockStatic(OpenAIOkHttpClient.class)) {

OpenAIOkHttpClient.Builder mockBuilder = mock(OpenAIOkHttpClient.Builder.class);
OpenAIClient mockOpenAIClient = mock(OpenAIClient.class);
EmbeddingService mockEmbeddings = mock(EmbeddingService.class);

when(mockBuilder.build()).thenReturn(mockOpenAIClient);
when(mockBuilder.apiKey(any())).thenReturn(mockBuilder);
when(mockBuilder.baseUrl(any(String.class))).thenReturn(mockBuilder);
when(mockBuilder.putHeader(any(), any())).thenReturn(mockBuilder);

mockedClient.when(OpenAIOkHttpClient::builder).thenReturn(mockBuilder);

when(mockOpenAIClient.embeddings()).thenReturn(mockEmbeddings);
when(mockEmbeddings.create(any(EmbeddingCreateParams.class))).thenReturn(mockResponse);

TextBlock textBlock = TextBlock.builder().text("Test open-source model").build();

StepVerifier.create(modelWithoutDimensions.embed(textBlock))
.assertNext(
embedding -> {
assertNotNull(embedding);
assertEquals(4, embedding.length);
assertEquals(0.5, embedding[0], 0.001);
assertEquals(0.8, embedding[3], 0.001);
})
.verifyComplete();
}
}

@Test
@DisplayName("Should not warn when returned embedding length matches configured dimensions")
void testEmbedWithMatchingDimensions() {
OpenAITextEmbedding modelWithMatchingDimensions =
OpenAITextEmbedding.builder()
.apiKey("mock_api_key")
.modelName(TEST_MODEL_NAME)
.dimensions(3)
.executionConfig(ExecutionConfig.builder().maxAttempts(1).build())
.build();

Embedding mockEmbedding = mock(Embedding.class);
when(mockEmbedding.embedding()).thenReturn(Arrays.asList(0.1f, 0.2f, 0.3f));

CreateEmbeddingResponse mockResponse = mock(CreateEmbeddingResponse.class);
when(mockResponse.data()).thenReturn(Arrays.asList(mockEmbedding));

try (MockedStatic<OpenAIOkHttpClient> mockedClient =
Mockito.mockStatic(OpenAIOkHttpClient.class)) {

OpenAIOkHttpClient.Builder mockBuilder = mock(OpenAIOkHttpClient.Builder.class);
OpenAIClient mockOpenAIClient = mock(OpenAIClient.class);
EmbeddingService mockEmbeddings = mock(EmbeddingService.class);

when(mockBuilder.build()).thenReturn(mockOpenAIClient);
when(mockBuilder.apiKey(any())).thenReturn(mockBuilder);
when(mockBuilder.baseUrl(any(String.class))).thenReturn(mockBuilder);
when(mockBuilder.putHeader(any(), any())).thenReturn(mockBuilder);

mockedClient.when(OpenAIOkHttpClient::builder).thenReturn(mockBuilder);

when(mockOpenAIClient.embeddings()).thenReturn(mockEmbeddings);
when(mockEmbeddings.create(any(EmbeddingCreateParams.class))).thenReturn(mockResponse);

TextBlock textBlock = TextBlock.builder().text("Matching dimensions").build();

StepVerifier.create(modelWithMatchingDimensions.embed(textBlock))
.assertNext(
embedding -> {
assertNotNull(embedding);
assertEquals(3, embedding.length);
})
.verifyComplete();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,52 @@ void testMinimalBuilder() {
assertNotNull(model);
}

@Test
@DisplayName(
"Should build successfully with dimensions explicitly omitted for open-source models")
void testBuilderWithoutDimensions() {
OpenAITextEmbedding model =
OpenAITextEmbedding.builder()
.apiKey(TEST_API_KEY)
.modelName("BAAI/bge-large-zh-v1.5")
.dimensions(null)
.build();

assertNotNull(model);
assertEquals("BAAI/bge-large-zh-v1.5", model.getModelName());
}

@Test
@DisplayName("Should default dimensions to 1536 when not explicitly set")
void testBuilderDefaultsDimensionsTo1536() {
OpenAITextEmbedding model =
OpenAITextEmbedding.builder()
.apiKey(TEST_API_KEY)
.modelName(TEST_MODEL_NAME)
.build();

assertEquals(
1536,
model.getDimensions(),
"Builder should default dimensions to 1536 for backward compatibility");
}

@Test
@DisplayName("Should return fallback dimensions (1024) when explicitly set to null")
void testGetDimensionsReturnsDefaultWhenExplicitlyNull() {
OpenAITextEmbedding model =
OpenAITextEmbedding.builder()
.apiKey(TEST_API_KEY)
.modelName(TEST_MODEL_NAME)
.dimensions(null)
.build();

assertEquals(
1024,
model.getDimensions(),
"getDimensions() should return default 1024 per EmbeddingModel interface");
}

@Test
@DisplayName("Should apply timeout configuration")
void testTimeoutConfiguration() {
Expand Down Expand Up @@ -259,5 +305,41 @@ void testDimensionsValidation() {
.modelName(TEST_MODEL_NAME)
.dimensions(-1)
.build());

// Building without calling dimensions() should NOT throw
OpenAITextEmbedding model =
OpenAITextEmbedding.builder()
.apiKey(TEST_API_KEY)
.modelName(TEST_MODEL_NAME)
.build();
assertNotNull(model);
}

@Test
@DisplayName("Should throw exception when constructed directly with non-positive dimensions")
void testDirectConstructorRejectsNonPositiveDimensions() {
assertThrows(
IllegalArgumentException.class,
() ->
new OpenAITextEmbedding(
TEST_API_KEY, TEST_MODEL_NAME, Integer.valueOf(0), null, null));

assertThrows(
IllegalArgumentException.class,
() ->
new OpenAITextEmbedding(
TEST_API_KEY, TEST_MODEL_NAME, Integer.valueOf(-1), null, null));
}

@Test
@DisplayName(
"Should support the deprecated int-dimensions constructor for binary compatibility")
void testDeprecatedIntConstructor() {
OpenAITextEmbedding model =
new OpenAITextEmbedding(TEST_API_KEY, TEST_MODEL_NAME, TEST_DIMENSIONS, null, null);

assertNotNull(model);
assertEquals(TEST_MODEL_NAME, model.getModelName());
assertEquals(TEST_DIMENSIONS, model.getDimensions());
}
}
Loading