Skip to content

Commit 60b9ff8

Browse files
committed
feat(ocr): add Mistral OCR auto-configuration and options
- Introduce `MistralAiOcrAutoConfiguration` for Spring Boot auto-configuration. - Add `MistralAiOcrProperties` for managing OCR-related settings. - Implement `MistralAiOcrOptions` for OCR request customization. - Add corresponding test classes for auto-configuration and options. Signed-off-by: Alexandros Pappas <apappascs@gmail.com>
1 parent 29071c7 commit 60b9ff8

File tree

8 files changed

+662
-3
lines changed

8 files changed

+662
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.mistralai.autoconfigure;
18+
19+
import org.springframework.ai.mistralai.ocr.MistralOcrApi;
20+
import org.springframework.ai.model.SpringAIModels;
21+
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
22+
import org.springframework.beans.factory.ObjectProvider;
23+
import org.springframework.boot.autoconfigure.AutoConfiguration;
24+
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
25+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
26+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
27+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
28+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
29+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
30+
import org.springframework.context.annotation.Bean;
31+
import org.springframework.web.client.ResponseErrorHandler;
32+
import org.springframework.web.client.RestClient;
33+
import org.springframework.util.Assert;
34+
import org.springframework.util.StringUtils;
35+
36+
/**
37+
* OCR {@link AutoConfiguration Auto-configuration} for Mistral AI OCR.
38+
*
39+
* @author Alexandros Pappas
40+
* @since 1.0.0
41+
*/
42+
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class })
43+
@ConditionalOnClass(MistralOcrApi.class)
44+
@ConditionalOnProperty(name = "spring.ai.model.ocr", havingValue = SpringAIModels.MISTRAL, matchIfMissing = true)
45+
@EnableConfigurationProperties({ MistralAiCommonProperties.class, MistralAiOcrProperties.class })
46+
@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class })
47+
public class MistralAiOcrAutoConfiguration {
48+
49+
@Bean
50+
@ConditionalOnMissingBean
51+
public MistralOcrApi mistralOcrApi(MistralAiCommonProperties commonProperties, MistralAiOcrProperties ocrProperties,
52+
ObjectProvider<RestClient.Builder> restClientBuilderProvider, ResponseErrorHandler responseErrorHandler) {
53+
54+
var apiKey = ocrProperties.getApiKey();
55+
var baseUrl = ocrProperties.getBaseUrl();
56+
57+
var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonProperties.getApiKey();
58+
var resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonProperties.getBaseUrl();
59+
60+
Assert.hasText(resolvedApiKey, "Mistral API key must be set");
61+
Assert.hasText(resolvedBaseUrl, "Mistral base URL must be set");
62+
63+
return new MistralOcrApi(resolvedBaseUrl, resolvedApiKey,
64+
restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler);
65+
}
66+
67+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.mistralai.autoconfigure;
18+
19+
import org.springframework.ai.mistralai.ocr.MistralAiOcrOptions;
20+
import org.springframework.ai.mistralai.ocr.MistralOcrApi;
21+
import org.springframework.boot.context.properties.ConfigurationProperties;
22+
import org.springframework.boot.context.properties.NestedConfigurationProperty;
23+
24+
/**
25+
* Configuration properties for Mistral AI OCR.
26+
*
27+
* @author Alexandros Pappas
28+
* @since 1.0.0
29+
*/
30+
@ConfigurationProperties(MistralAiOcrProperties.CONFIG_PREFIX)
31+
public class MistralAiOcrProperties extends MistralAiParentProperties {
32+
33+
public static final String CONFIG_PREFIX = "spring.ai.mistralai.ocr";
34+
35+
public static final String DEFAULT_OCR_MODEL = MistralOcrApi.OCRModel.MISTRAL_OCR_LATEST.getValue();
36+
37+
@NestedConfigurationProperty
38+
private MistralAiOcrOptions options = MistralAiOcrOptions.builder().model(DEFAULT_OCR_MODEL).build();
39+
40+
public MistralAiOcrProperties() {
41+
super.setBaseUrl(MistralAiCommonProperties.DEFAULT_BASE_URL);
42+
}
43+
44+
public MistralAiOcrOptions getOptions() {
45+
return this.options;
46+
}
47+
48+
public void setOptions(MistralAiOcrOptions options) {
49+
this.options = options;
50+
}
51+
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.mistralai.autoconfigure;
18+
19+
import java.util.List;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
23+
24+
import org.springframework.ai.mistralai.ocr.MistralOcrApi;
25+
import org.springframework.boot.autoconfigure.AutoConfigurations;
26+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
27+
import org.springframework.http.ResponseEntity;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
31+
/**
32+
* Integration Tests for {@link MistralAiOcrAutoConfiguration}.
33+
*
34+
* <p>
35+
* These tests require the {@code MISTRAL_AI_API_KEY} environment variable to be set. They
36+
* verify that the {@link MistralOcrApi} bean is correctly configured and can interact
37+
* with the Mistral AI OCR API
38+
* </p>
39+
*
40+
* @author Alexandros Pappas
41+
* @since 1.0.0
42+
*/
43+
@EnabledIfEnvironmentVariable(named = MistralAiOcrAutoConfigurationTests.ENV_VAR_NAME, matches = ".*")
44+
class MistralAiOcrAutoConfigurationTests {
45+
46+
static final String ENV_VAR_NAME = "MISTRAL_AI_API_KEY";
47+
48+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
49+
.withPropertyValues("spring.ai.mistralai.api-key=" + System.getenv(ENV_VAR_NAME))
50+
.withConfiguration(AutoConfigurations.of(MistralAiOcrAutoConfiguration.class));
51+
52+
@Test
53+
void ocrExtractionWithPublicUrl() {
54+
this.contextRunner.run(context -> {
55+
56+
MistralOcrApi mistralOcrApi = context.getBean(MistralOcrApi.class);
57+
assertThat(mistralOcrApi).isNotNull();
58+
59+
String documentUrl = "https://arxiv.org/pdf/2201.04234";
60+
MistralOcrApi.OCRRequest request = new MistralOcrApi.OCRRequest(
61+
MistralOcrApi.OCRModel.MISTRAL_OCR_LATEST.getValue(), "test_id",
62+
new MistralOcrApi.OCRRequest.DocumentURLChunk(documentUrl), List.of(0, 1), true, 2, 50);
63+
64+
ResponseEntity<MistralOcrApi.OCRResponse> response = mistralOcrApi.ocr(request);
65+
66+
assertThat(response).isNotNull();
67+
assertThat(response.getBody()).isNotNull();
68+
assertThat(response.getBody().pages()).isNotNull();
69+
assertThat(response.getBody().pages()).isNotEmpty();
70+
assertThat(response.getBody().pages().get(0).markdown()).isNotEmpty();
71+
72+
if (request.includeImageBase64() != null && request.includeImageBase64()) {
73+
assertThat(response.getBody().pages().get(1).images()).isNotNull();
74+
assertThat(response.getBody().pages().get(1).images().get(0).imageBase64()).isNotNull();
75+
}
76+
77+
});
78+
}
79+
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.mistralai.autoconfigure;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.ai.mistralai.ocr.MistralOcrApi;
22+
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
23+
import org.springframework.boot.autoconfigure.AutoConfigurations;
24+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
25+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
29+
/**
30+
* Unit Tests for {@link MistralAiOcrProperties} interacting with
31+
* {@link MistralAiCommonProperties}.
32+
*
33+
* @author Alexandros Pappas
34+
* @since 1.0.0
35+
*/
36+
class MistralAiOcrPropertiesTests {
37+
38+
// Define common configurations to load in tests
39+
private final AutoConfigurations autoConfigurations = AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
40+
RestClientAutoConfiguration.class, MistralAiOcrAutoConfiguration.class);
41+
42+
@Test
43+
void commonPropertiesAppliedToOcr() {
44+
new ApplicationContextRunner()
45+
.withPropertyValues("spring.ai.mistralai.base-url=COMMON_BASE_URL",
46+
"spring.ai.mistralai.api-key=COMMON_API_KEY",
47+
"spring.ai.mistralai.ocr.options.model=mistral-ocr-specific-model")
48+
.withConfiguration(this.autoConfigurations)
49+
.run(context -> {
50+
assertThat(context).hasSingleBean(MistralAiCommonProperties.class);
51+
assertThat(context).hasSingleBean(MistralAiOcrProperties.class);
52+
53+
var commonProps = context.getBean(MistralAiCommonProperties.class);
54+
var ocrProps = context.getBean(MistralAiOcrProperties.class);
55+
56+
assertThat(commonProps.getBaseUrl()).isEqualTo("COMMON_BASE_URL");
57+
assertThat(commonProps.getApiKey()).isEqualTo("COMMON_API_KEY");
58+
59+
assertThat(ocrProps.getBaseUrl()).isEqualTo(MistralAiCommonProperties.DEFAULT_BASE_URL);
60+
assertThat(ocrProps.getApiKey()).isNull();
61+
62+
assertThat(ocrProps.getOptions()).isNotNull();
63+
assertThat(ocrProps.getOptions().getModel()).isEqualTo("mistral-ocr-specific-model");
64+
65+
assertThat(context).hasSingleBean(MistralOcrApi.class);
66+
});
67+
}
68+
69+
@Test
70+
void ocrSpecificPropertiesOverrideCommon() {
71+
new ApplicationContextRunner()
72+
.withPropertyValues("spring.ai.mistralai.base-url=COMMON_BASE_URL",
73+
"spring.ai.mistralai.api-key=COMMON_API_KEY", "spring.ai.mistralai.ocr.base-url=OCR_BASE_URL",
74+
"spring.ai.mistralai.ocr.api-key=OCR_API_KEY",
75+
"spring.ai.mistralai.ocr.options.model=mistral-ocr-default")
76+
.withConfiguration(this.autoConfigurations)
77+
.run(context -> {
78+
assertThat(context).hasSingleBean(MistralAiCommonProperties.class);
79+
assertThat(context).hasSingleBean(MistralAiOcrProperties.class);
80+
81+
var commonProps = context.getBean(MistralAiCommonProperties.class);
82+
var ocrProps = context.getBean(MistralAiOcrProperties.class);
83+
84+
assertThat(commonProps.getBaseUrl()).isEqualTo("COMMON_BASE_URL");
85+
assertThat(commonProps.getApiKey()).isEqualTo("COMMON_API_KEY");
86+
87+
assertThat(ocrProps.getBaseUrl()).isEqualTo("OCR_BASE_URL");
88+
assertThat(ocrProps.getApiKey()).isEqualTo("OCR_API_KEY");
89+
90+
assertThat(ocrProps.getOptions()).isNotNull();
91+
assertThat(ocrProps.getOptions().getModel()).isEqualTo("mistral-ocr-default");
92+
93+
assertThat(context).hasSingleBean(MistralOcrApi.class);
94+
});
95+
}
96+
97+
@Test
98+
void ocrOptionsBinding() {
99+
new ApplicationContextRunner().withPropertyValues("spring.ai.mistralai.api-key=API_KEY",
100+
"spring.ai.mistralai.ocr.options.model=custom-ocr-model",
101+
"spring.ai.mistralai.ocr.options.id=ocr-request-id-123", "spring.ai.mistralai.ocr.options.pages=0,1,5",
102+
"spring.ai.mistralai.ocr.options.includeImageBase64=true",
103+
"spring.ai.mistralai.ocr.options.imageLimit=25", "spring.ai.mistralai.ocr.options.imageMinSize=150")
104+
.withConfiguration(this.autoConfigurations)
105+
.run(context -> {
106+
assertThat(context).hasSingleBean(MistralAiOcrProperties.class);
107+
var ocrProps = context.getBean(MistralAiOcrProperties.class);
108+
var options = ocrProps.getOptions();
109+
110+
assertThat(options).isNotNull();
111+
assertThat(options.getModel()).isEqualTo("custom-ocr-model");
112+
assertThat(options.getId()).isEqualTo("ocr-request-id-123");
113+
assertThat(options.getPages()).containsExactly(0, 1, 5);
114+
assertThat(options.getIncludeImageBase64()).isTrue();
115+
assertThat(options.getImageLimit()).isEqualTo(25);
116+
assertThat(options.getImageMinSize()).isEqualTo(150);
117+
});
118+
}
119+
120+
@Test
121+
void ocrActivationViaModelProperty() {
122+
// Scenario 1: OCR explicitly disabled
123+
new ApplicationContextRunner().withConfiguration(this.autoConfigurations)
124+
.withPropertyValues("spring.ai.mistralai.api-key=API_KEY", "spring.ai.model.ocr=none")
125+
.run(context -> {
126+
assertThat(context.getBeansOfType(MistralAiOcrProperties.class)).isEmpty();
127+
assertThat(context.getBeansOfType(MistralOcrApi.class)).isEmpty();
128+
// Should not have common properties either if only OCR config was loaded
129+
// and then disabled
130+
assertThat(context.getBeansOfType(MistralAiCommonProperties.class)).isEmpty();
131+
});
132+
133+
// Scenario 2: OCR explicitly enabled for 'mistral'
134+
new ApplicationContextRunner().withConfiguration(this.autoConfigurations)
135+
.withPropertyValues("spring.ai.mistralai.api-key=API_KEY", "spring.ai.model.ocr=mistral")
136+
.run(context -> {
137+
assertThat(context).hasSingleBean(MistralAiCommonProperties.class); // Enabled
138+
// by
139+
// MistralAiOcrAutoConfiguration
140+
assertThat(context).hasSingleBean(MistralAiOcrProperties.class);
141+
assertThat(context).hasSingleBean(MistralOcrApi.class);
142+
});
143+
144+
// Scenario 3: OCR implicitly enabled (default behavior when property is absent)
145+
new ApplicationContextRunner().withConfiguration(this.autoConfigurations)
146+
.withPropertyValues("spring.ai.mistralai.api-key=API_KEY")
147+
.run(context -> {
148+
assertThat(context).hasSingleBean(MistralAiCommonProperties.class); // Enabled
149+
// by
150+
// MistralAiOcrAutoConfiguration
151+
assertThat(context).hasSingleBean(MistralAiOcrProperties.class);
152+
assertThat(context).hasSingleBean(MistralOcrApi.class);
153+
});
154+
155+
// Scenario 4: OCR implicitly disabled when another provider is chosen
156+
new ApplicationContextRunner().withConfiguration(this.autoConfigurations)
157+
.withPropertyValues("spring.ai.mistralai.api-key=API_KEY", "spring.ai.model.ocr=some-other-provider")
158+
.run(context -> {
159+
assertThat(context.getBeansOfType(MistralAiOcrProperties.class)).isEmpty();
160+
assertThat(context.getBeansOfType(MistralOcrApi.class)).isEmpty();
161+
// Common properties might still be loaded if another Mistral AI config
162+
// (like Chat) was active,
163+
// but in this minimal test setup, they shouldn't be loaded if OCR is
164+
// disabled.
165+
assertThat(context.getBeansOfType(MistralAiCommonProperties.class)).isEmpty();
166+
});
167+
}
168+
169+
}

0 commit comments

Comments
 (0)