+ * This test configuration allows Spring AI framework developers to mock Azure OpenAI's + * API with Spring {@link MockMvc} and a test provided Spring Web MVC + * {@link org.springframework.web.bind.annotation.RestController}. + *
+ * This test configuration makes use of the OkHttp3 {@link MockWebServer} and
+ * {@link Dispatcher} to integrate with Spring {@link MockMvc}.
+ *
+ * @author John Blum
+ * @see org.springframework.boot.SpringBootConfiguration
+ * @see org.springframework.ai.test.config.MockAiTestConfiguration
+ * @since 0.7.0
+ */
+@SpringBootConfiguration
+@Profile("spring-ai-azure-openai-mocks")
+@Import(MockAiTestConfiguration.class)
+@SuppressWarnings("unused")
+public class MockAzureOpenAiTestConfiguration {
+
+ @Bean
+ OpenAIClient microsoftAzureOpenAiClient(MockWebServer webServer) {
+
+ HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH);
+
+ return new OpenAIClientBuilder().endpoint(baseUrl.toString()).buildClient();
+ }
+
+ @Bean
+ AzureOpenAiClient azureOpenAiClient(OpenAIClient microsoftAzureOpenAiClient) {
+ return new AzureOpenAiClient(microsoftAzureOpenAiClient);
+ }
+
+}
diff --git a/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/client/AzureOpenAiClientMetadataTests.java b/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/client/AzureOpenAiClientMetadataTests.java
new file mode 100644
index 000000000..b840793e9
--- /dev/null
+++ b/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/client/AzureOpenAiClientMetadataTests.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.ai.azure.openai.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
+
+import java.nio.charset.StandardCharsets;
+
+import com.azure.ai.openai.models.ContentFilterResult;
+import com.azure.ai.openai.models.ContentFilterResults;
+import com.azure.ai.openai.models.ContentFilterSeverity;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.ai.azure.openai.MockAzureOpenAiTestConfiguration;
+import org.springframework.ai.client.AiResponse;
+import org.springframework.ai.client.Generation;
+import org.springframework.ai.metadata.ChoiceMetadata;
+import org.springframework.ai.metadata.GenerationMetadata;
+import org.springframework.ai.metadata.PromptMetadata;
+import org.springframework.ai.metadata.RateLimit;
+import org.springframework.ai.metadata.Usage;
+import org.springframework.ai.prompt.Prompt;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.WebRequest;
+
+/**
+ * Unit Tests for {@link AzureOpenAiClient} asserting AI metadata.
+ *
+ * @author John Blum
+ * @since 0.7.0
+ */
+@SpringBootTest
+@ActiveProfiles("spring-ai-azure-openai-mocks")
+@ContextConfiguration(classes = AzureOpenAiClientMetadataTests.TestConfiguration.class)
+@SuppressWarnings("unused")
+class AzureOpenAiClientMetadataTests {
+
+ @Autowired
+ private AzureOpenAiClient aiClient;
+
+ @Test
+ void azureOpenAiMetadataCapturedDuringGeneration() {
+
+ Prompt prompt = new Prompt("Can I fly like a bird?");
+
+ AiResponse response = this.aiClient.generate(prompt);
+
+ assertThat(response).isNotNull();
+
+ Generation generation = response.getGeneration();
+
+ assertThat(generation).isNotNull()
+ .extracting(Generation::getText)
+ .isEqualTo("No! You will actually land with a resounding thud. This is the way!");
+
+ assertPromptMetadata(response);
+ assertGenerationMetadata(response);
+ assertChoiceMetadata(generation);
+ }
+
+ private void assertPromptMetadata(AiResponse response) {
+
+ PromptMetadata promptMetadata = response.getPromptMetadata();
+
+ assertThat(promptMetadata).isNotNull();
+
+ PromptMetadata.PromptFilterMetadata promptFilterMetadata = promptMetadata.findByPromptIndex(0).orElse(null);
+
+ assertThat(promptFilterMetadata).isNotNull();
+ assertThat(promptFilterMetadata.getPromptIndex()).isZero();
+ assertContentFilterResults(promptFilterMetadata.getContentFilterMetadata(), ContentFilterSeverity.HIGH);
+ }
+
+ private void assertGenerationMetadata(AiResponse response) {
+
+ GenerationMetadata generationMetadata = response.getGenerationMetadata();
+
+ assertThat(generationMetadata).isNotNull();
+ assertThat(generationMetadata.getRateLimit()).isEqualTo(RateLimit.NULL);
+
+ Usage usage = generationMetadata.getUsage();
+
+ assertThat(usage).isNotNull();
+ assertThat(usage).isNotEqualTo(Usage.NULL);
+ assertThat(usage.getPromptTokens()).isEqualTo(58);
+ assertThat(usage.getGenerationTokens()).isEqualTo(68);
+ assertThat(usage.getTotalTokens()).isEqualTo(126);
+ }
+
+ private void assertChoiceMetadata(Generation generation) {
+
+ ChoiceMetadata choiceMetadata = generation.getChoiceMetadata();
+
+ assertThat(choiceMetadata).isNotNull();
+ assertThat(choiceMetadata.getFinishReason()).isEqualTo("stop");
+ assertContentFilterResults(choiceMetadata.getContentFilterMetadata());
+ }
+
+ private void assertContentFilterResults(ContentFilterResults contentFilterResults) {
+ assertContentFilterResults(contentFilterResults, ContentFilterSeverity.SAFE);
+ }
+
+ private void assertContentFilterResults(ContentFilterResults contentFilterResults,
+ ContentFilterSeverity selfHarmSeverity) {
+
+ assertThat(contentFilterResults).isNotNull();
+ assertContentFilterResult(contentFilterResults.getHate());
+ assertContentFilterResult(contentFilterResults.getSelfHarm(), selfHarmSeverity);
+ assertContentFilterResult(contentFilterResults.getSexual());
+ assertContentFilterResult(contentFilterResults.getViolence());
+ }
+
+ private void assertContentFilterResult(ContentFilterResult contentFilterResult) {
+
+ assertThat(contentFilterResult).isNotNull();
+ assertContentFilterResult(contentFilterResult, contentFilterResult.getSeverity());
+ }
+
+ private void assertContentFilterResult(ContentFilterResult contentFilterResult,
+ ContentFilterSeverity expectedSeverity) {
+
+ boolean filtered = !ContentFilterSeverity.SAFE.equals(expectedSeverity);
+
+ assertThat(contentFilterResult).isNotNull();
+ assertThat(contentFilterResult.isFiltered()).isEqualTo(filtered);
+ assertThat(contentFilterResult.getSeverity()).isEqualTo(expectedSeverity);
+ }
+
+ @SpringBootConfiguration
+ @Profile("spring-ai-azure-openai-mocks")
+ @Import(MockAzureOpenAiTestConfiguration.class)
+ static class TestConfiguration {
+
+ @Bean
+ MockMvc mockMvc() {
+ return MockMvcBuilders.standaloneSetup(new SpringAzureOpenAiChatCompletionsController()).build();
+ }
+
+ }
+
+ @RestController
+ @RequestMapping(SPRING_AI_API_PATH)
+ @SuppressWarnings("all")
+ static class SpringAzureOpenAiChatCompletionsController {
+
+ @PostMapping("/openai/deployments/gpt-35-turbo/chat/completions")
+ ResponseEntity> chatCompletions(WebRequest request) {
+
+ String json = getJson();
+
+ ResponseEntity> response = ResponseEntity.status(HttpStatusCode.valueOf(200))
+ .contentType(MediaType.APPLICATION_JSON)
+ .contentLength(json.getBytes(StandardCharsets.UTF_8).length)
+ .body(getJson());
+
+ return response;
+ }
+
+ private String getJson() {
+ return """
+ {
+ "id": "chatcmpl-6v7mkQj980V1yBec6ETrKPRqFjNw9",
+ "object": "chat.completion",
+ "created": 1679072642,
+ "model": "gpt-35-turbo",
+ "choices":[{
+ "index": 0,
+ "content_filter_results" : {
+ "error" : null,
+ "hate" : {
+ "filtered" : false,
+ "severity" : "safe"
+ },
+ "self_harm" : {
+ "filtered" : false,
+ "severity" : "safe"
+ },
+ "sexual" : {
+ "filtered" : false,
+ "severity" : "safe"
+ },
+ "violence" : {
+ "filtered" : false,
+ "severity" : "safe"
+ }
+ },
+ "finish_reason": "stop",
+ "message":{
+ "role": "user",
+ "content": "No! You will actually land with a resounding thud. This is the way!"
+ }
+ }],
+ "usage":{
+ "prompt_tokens":58,
+ "completion_tokens":68,
+ "total_tokens":126
+ },
+ "prompt_annotations" : [{
+ "prompt_index" : 0,
+ "content_filter_results" : {
+ "error" : null,
+ "hate" : {
+ "filtered" : false,
+ "severity" : "safe"
+ },
+ "self_harm" : {
+ "filtered" : true,
+ "severity" : "high"
+ },
+ "sexual" : {
+ "filtered" : false,
+ "severity" : "safe"
+ },
+ "violence" : {
+ "filtered" : false,
+ "severity" : "safe"
+ }
+ }
+ }]
+ }
+ """;
+ }
+
+ }
+
+}
diff --git a/spring-ai-openai/pom.xml b/spring-ai-openai/pom.xml
index 7aa476df6..51d68cd14 100644
--- a/spring-ai-openai/pom.xml
+++ b/spring-ai-openai/pom.xml
@@ -68,26 +68,9 @@
+ * This test configuration allows Spring AI framework developers to mock OpenAI's API with + * Spring {@link MockMvc} and a test provided Spring Web MVC + * {@link org.springframework.web.bind.annotation.RestController}. + *
+ * This test configuration makes use of the OkHttp3 {@link MockWebServer} and
+ * {@link Dispatcher} to integrate with Spring {@link MockMvc}.
+ *
+ * @author John Blum
+ * @see org.springframework.boot.SpringBootConfiguration
+ * @see org.springframework.ai.test.config.MockAiTestConfiguration
+ * @since 0.7.0
+ */
+@SpringBootConfiguration
+@Profile("spring-ai-openai-mocks")
+@Import(MockAiTestConfiguration.class)
+@SuppressWarnings("unused")
+public class MockOpenAiTestConfiguration {
+
+ @Bean
+ OpenAiService theoOpenAiService(MockWebServer webServer) {
+
+ String apiKey = UUID.randomUUID().toString();
+ Duration timeout = Duration.ofSeconds(60);
+
+ ObjectMapper objectMapper = OpenAiService.defaultObjectMapper();
+
+ OkHttpClient httpClient = new OkHttpClient.Builder(OpenAiService.defaultClient(apiKey, timeout))
+ .addInterceptor(new OpenAiHttpResponseHeadersInterceptor())
+ .build();
+
+ HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH.concat("/"));
+
+ Retrofit retrofit = new Retrofit.Builder().baseUrl(baseUrl)
+ .addConverterFactory(JacksonConverterFactory.create(objectMapper))
+ .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
+ .client(httpClient)
+ .build();
+
+ OpenAiApi api = retrofit.create(OpenAiApi.class);
+
+ return new OpenAiService(api);
+ }
+
+ @Bean
+ OpenAiClient apiClient(OpenAiService openAiService) {
+ return new OpenAiClient(openAiService);
+ }
+
+}
diff --git a/spring-ai-openai/src/test/java/org/springframework/ai/openai/client/OpenAiClientWithGenerationMetadataTests.java b/spring-ai-openai/src/test/java/org/springframework/ai/openai/client/OpenAiClientWithGenerationMetadataTests.java
index 9a5e1332e..30ce86faf 100644
--- a/spring-ai-openai/src/test/java/org/springframework/ai/openai/client/OpenAiClientWithGenerationMetadataTests.java
+++ b/spring-ai-openai/src/test/java/org/springframework/ai/openai/client/OpenAiClientWithGenerationMetadataTests.java
@@ -17,6 +17,7 @@
package org.springframework.ai.openai.client;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -29,7 +30,7 @@ import org.springframework.ai.metadata.GenerationMetadata;
import org.springframework.ai.metadata.PromptMetadata;
import org.springframework.ai.metadata.RateLimit;
import org.springframework.ai.metadata.Usage;
-import org.springframework.ai.openai.OpenAiMockTestConfiguration;
+import org.springframework.ai.openai.MockOpenAiTestConfiguration;
import org.springframework.ai.openai.metadata.support.OpenAiApiResponseHeaders;
import org.springframework.ai.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
@@ -58,8 +59,8 @@ import org.springframework.web.context.request.WebRequest;
* @since 0.7.0
*/
@SpringBootTest
-@ContextConfiguration(classes = OpenAiClientWithGenerationMetadataTests.TestConfiguration.class)
@ActiveProfiles("spring-ai-openai-mocks")
+@ContextConfiguration(classes = OpenAiClientWithGenerationMetadataTests.TestConfiguration.class)
@SuppressWarnings("unused")
class OpenAiClientWithGenerationMetadataTests {
@@ -119,7 +120,7 @@ class OpenAiClientWithGenerationMetadataTests {
}
@SpringBootConfiguration
- @Import(OpenAiMockTestConfiguration.class)
+ @Import(MockOpenAiTestConfiguration.class)
static class TestConfiguration {
@Bean
@@ -130,7 +131,7 @@ class OpenAiClientWithGenerationMetadataTests {
}
@RestController
- @RequestMapping("/spring-ai/api")
+ @RequestMapping(SPRING_AI_API_PATH)
@SuppressWarnings("all")
static class SpringOpenAiChatCompletionsController {
diff --git a/spring-ai-test/pom.xml b/spring-ai-test/pom.xml
index a978a7efe..8624173dc 100644
--- a/spring-ai-test/pom.xml
+++ b/spring-ai-test/pom.xml
@@ -24,16 +24,30 @@
- * This test configuration allows Spring AI framework developers to mock OpenAI's API with - * Spring {@link MockMvc} and a test provided Spring Web MVC + * This test configuration allows Spring AI framework developers to mock an AI provider's + * APIs with Spring {@link MockMvc} and a test provided Spring Web MVC * {@link org.springframework.web.bind.annotation.RestController}. *
* This test configuration makes use of the OkHttp3 {@link MockWebServer} and - * {@link Dispatcher} to integrate with Spring {@link MockMvc}. + * {@link Dispatcher} to integrate with Spring {@link MockMvc}. This allows you to mock + * the AI response (e.g. JSON) coming back from the AI provider API and let it pass + * through the underlying AI client library and infrastructure components responsible for + * accessing the provider's AI with its API all the way back to Spring AI. * * @author John Blum * @see okhttp3.mockwebserver.Dispatcher @@ -81,14 +70,13 @@ import retrofit2.converter.jackson.JacksonConverterFactory; * @see org.springframework.test.web.servlet.MockMvc * @since 0.7.0 */ -@SpringBootConfiguration -@Profile("spring-ai-openai-mocks") +@Configuration @SuppressWarnings("unused") -public class OpenAiMockTestConfiguration { +public class MockAiTestConfiguration { - private static final Charset FALLBACK_CHARSET = StandardCharsets.UTF_8; + public static final Charset FALLBACK_CHARSET = StandardCharsets.UTF_8; - private static final String SPRING_AI_API_PATH = "/spring-ai/api"; + public static final String SPRING_AI_API_PATH = "/spring-ai/api"; @Bean MockWebServerFactoryBean mockWebServer(MockMvc mockMvc) { @@ -97,36 +85,12 @@ public class OpenAiMockTestConfiguration { return factoryBean; } - @Bean - OpenAiService theoOpenAiService(MockWebServer webServer) { - - String apiKey = UUID.randomUUID().toString(); - Duration timeout = Duration.ofSeconds(60); - - ObjectMapper objectMapper = OpenAiService.defaultObjectMapper(); - - OkHttpClient httpClient = new OkHttpClient.Builder(OpenAiService.defaultClient(apiKey, timeout)) - .addInterceptor(new OpenAiHttpResponseHeadersInterceptor()) - .build(); - - HttpUrl baseUrl = webServer.url(SPRING_AI_API_PATH.concat("/")); - - Retrofit retrofit = new Retrofit.Builder().baseUrl(baseUrl) - .addConverterFactory(JacksonConverterFactory.create(objectMapper)) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .client(httpClient) - .build(); - - OpenAiApi api = retrofit.create(OpenAiApi.class); - - return new OpenAiService(api); - } - - @Bean - OpenAiClient apiClient(OpenAiService openAiService) { - return new OpenAiClient(openAiService); - } - + /** + * OkHttp {@link Dispatcher} implementation integrated with Spring Web MVC. + * + * @see okhttp3.mockwebserver.Dispatcher + * @see org.springframework.test.web.servlet.MockMvc + */ static class MockMvcDispatcher extends Dispatcher { private final MockMvc mockMvc; @@ -141,6 +105,7 @@ public class OpenAiMockTestConfiguration { } @Override + @SuppressWarnings("all") public MockResponse dispatch(RecordedRequest request) { try { @@ -213,7 +178,7 @@ public class OpenAiMockTestConfiguration { } /** - * Spring {@link FactoryBean} used to construct, configure and properly initialize the + * Spring {@link FactoryBean} used to construct, configure and initialize the * {@link MockWebServer} inside the Spring container. *
* Unfortunately, {@link MockWebServerFactoryBean} cannot implement the Spring @@ -226,8 +191,8 @@ public class OpenAiMockTestConfiguration { *
* TODO: Figure out a way to implement the Spring {@link SmartLifecycle} interface @@ -235,6 +200,7 @@ public class OpenAiMockTestConfiguration { * methods. * * @see org.springframework.beans.factory.FactoryBean + * @see org.springframework.beans.factory.DisposableBean * @see org.springframework.beans.factory.InitializingBean * @see okhttp3.mockwebserver.MockWebServer */