diff --git a/spring-ai-azure-openai/pom.xml b/spring-ai-azure-openai/pom.xml index dab351b01..e0c3fd4e7 100644 --- a/spring-ai-azure-openai/pom.xml +++ b/spring-ai-azure-openai/pom.xml @@ -45,6 +45,13 @@ + + org.springframework.experimental.ai + spring-ai-test + ${project.version} + test + + org.springframework.boot spring-boot-starter-test diff --git a/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/client/AzureOpenAiClient.java b/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/client/AzureOpenAiClient.java index 955dccabf..656ba0adf 100644 --- a/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/client/AzureOpenAiClient.java +++ b/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/client/AzureOpenAiClient.java @@ -16,27 +16,31 @@ package org.springframework.ai.azure.openai.client; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import com.azure.ai.openai.OpenAIClient; import com.azure.ai.openai.models.ChatChoice; import com.azure.ai.openai.models.ChatCompletions; import com.azure.ai.openai.models.ChatCompletionsOptions; import com.azure.ai.openai.models.ChatMessage; import com.azure.ai.openai.models.ChatRole; +import com.azure.ai.openai.models.PromptFilterResult; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.azure.openai.metadata.AzureOpenAiGenerationMetadata; import org.springframework.ai.client.AiClient; import org.springframework.ai.client.AiResponse; import org.springframework.ai.client.Generation; +import org.springframework.ai.metadata.ChoiceMetadata; import org.springframework.ai.metadata.PromptMetadata; import org.springframework.ai.metadata.PromptMetadata.PromptFilterMetadata; import org.springframework.ai.prompt.Prompt; import org.springframework.ai.prompt.messages.Message; import org.springframework.util.Assert; -import java.util.ArrayList; -import java.util.List; - /** * {@link AiClient} implementation for {@literal Microsoft Azure AI} backed by * {@link OpenAIClient}. @@ -55,11 +59,11 @@ public class AzureOpenAiClient implements AiClient { private final Logger logger = LoggerFactory.getLogger(getClass()); - private final OpenAIClient msoftOpenAiClient; + private final OpenAIClient openAIClient; public AzureOpenAiClient(OpenAIClient microsoftOpenAiClient) { Assert.notNull(microsoftOpenAiClient, "com.azure.ai.openai.OpenAIClient must not be null"); - this.msoftOpenAiClient = microsoftOpenAiClient; + this.openAIClient = microsoftOpenAiClient; } public String getModel() { @@ -88,7 +92,7 @@ public class AzureOpenAiClient implements AiClient { options.setModel(this.getModel()); logger.trace("Azure Chat Message: {}", azureChatMessage); - ChatCompletions chatCompletions = this.msoftOpenAiClient.getChatCompletions(this.getModel(), options); + ChatCompletions chatCompletions = this.openAIClient.getChatCompletions(this.getModel(), options); logger.trace("Azure ChatCompletions: {}", chatCompletions); StringBuilder stringBuilder = new StringBuilder(); @@ -120,14 +124,15 @@ public class AzureOpenAiClient implements AiClient { options.setModel(this.getModel()); logger.trace("Azure ChatCompletionsOptions: {}", options); - ChatCompletions chatCompletions = this.msoftOpenAiClient.getChatCompletions(this.getModel(), options); + ChatCompletions chatCompletions = this.openAIClient.getChatCompletions(this.getModel(), options); logger.trace("Azure ChatCompletions: {}", chatCompletions); List generations = new ArrayList<>(); for (ChatChoice choice : chatCompletions.getChoices()) { ChatMessage choiceMessage = choice.getMessage(); - Generation generation = new Generation(choiceMessage.getContent()); + Generation generation = new Generation(choiceMessage.getContent()) + .withChoiceMetadata(generateChoiceMetadata(choice)); generations.add(generation); } @@ -135,14 +140,22 @@ public class AzureOpenAiClient implements AiClient { .withPromptMetadata(generatePromptMetadata(chatCompletions)); } + private ChoiceMetadata generateChoiceMetadata(ChatChoice choice) { + return ChoiceMetadata.from(String.valueOf(choice.getFinishReason()), choice.getContentFilterResults()); + } + private PromptMetadata generatePromptMetadata(ChatCompletions chatCompletions) { - return PromptMetadata.of(chatCompletions.getPromptFilterResults() - .stream() + List promptFilterResults = nullSafeList(chatCompletions.getPromptFilterResults()); + + return PromptMetadata.of(promptFilterResults.stream() .map(promptFilterResult -> PromptFilterMetadata.from(promptFilterResult.getPromptIndex(), promptFilterResult.getContentFilterResults())) .toList()); + } + private List nullSafeList(List list) { + return list != null ? list : Collections.emptyList(); } } diff --git a/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/metadata/AzureOpenAiUsage.java b/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/metadata/AzureOpenAiUsage.java index 928d4b71a..73bdc4772 100644 --- a/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/metadata/AzureOpenAiUsage.java +++ b/spring-ai-azure-openai/src/main/java/org/springframework/ai/azure/openai/metadata/AzureOpenAiUsage.java @@ -43,7 +43,7 @@ public class AzureOpenAiUsage implements Usage { private final CompletionsUsage usage; public AzureOpenAiUsage(CompletionsUsage usage) { - Assert.notNull(usage, "CompletionUsage must not be null"); + Assert.notNull(usage, "CompletionsUsage must not be null"); this.usage = usage; } @@ -53,17 +53,17 @@ public class AzureOpenAiUsage implements Usage { @Override public Long getPromptTokens() { - return Integer.valueOf(getUsage().getPromptTokens()).longValue(); + return (long) getUsage().getPromptTokens(); } @Override public Long getGenerationTokens() { - return Integer.valueOf(getUsage().getCompletionTokens()).longValue(); + return (long) getUsage().getCompletionTokens(); } @Override public Long getTotalTokens() { - return Integer.valueOf(getUsage().getTotalTokens()).longValue(); + return (long) getUsage().getTotalTokens(); } @Override diff --git a/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/MockAzureOpenAiTestConfiguration.java b/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/MockAzureOpenAiTestConfiguration.java new file mode 100644 index 000000000..4e8068571 --- /dev/null +++ b/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/MockAzureOpenAiTestConfiguration.java @@ -0,0 +1,71 @@ +/* + * 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; + +import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH; + +import com.azure.ai.openai.OpenAIClient; +import com.azure.ai.openai.OpenAIClientBuilder; + +import org.springframework.ai.azure.openai.client.AzureOpenAiClient; +import org.springframework.ai.test.config.MockAiTestConfiguration; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.test.web.servlet.MockMvc; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockWebServer; + +/** + * {@link SpringBootConfiguration} for testing {@literal Azure OpenAI's} API using mock + * objects. + *

+ * 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 @@ - org.springframework.boot - spring-boot-starter-test - test - - - - jakarta.servlet - jakarta.servlet-api - test - - - - org.springframework - spring-webmvc - test - - - - com.squareup.okhttp3 - mockwebserver + org.springframework.experimental.ai + spring-ai-test + ${project.version} test diff --git a/spring-ai-openai/src/test/java/org/springframework/ai/openai/MockOpenAiTestConfiguration.java b/spring-ai-openai/src/test/java/org/springframework/ai/openai/MockOpenAiTestConfiguration.java new file mode 100644 index 000000000..0822eb3d7 --- /dev/null +++ b/spring-ai-openai/src/test/java/org/springframework/ai/openai/MockOpenAiTestConfiguration.java @@ -0,0 +1,96 @@ +/* + * 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.openai; + +import static org.springframework.ai.test.config.MockAiTestConfiguration.SPRING_AI_API_PATH; + +import java.time.Duration; +import java.util.UUID; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.theokanning.openai.client.OpenAiApi; +import com.theokanning.openai.service.OpenAiService; + +import org.springframework.ai.openai.client.OpenAiClient; +import org.springframework.ai.openai.metadata.support.OpenAiHttpResponseHeadersInterceptor; +import org.springframework.ai.test.config.MockAiTestConfiguration; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.test.web.servlet.MockMvc; + +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockWebServer; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.jackson.JacksonConverterFactory; + +/** + * {@link SpringBootConfiguration} for testing {@literal OpenAI's} API using mock objects. + *

+ * 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 @@ + + jakarta.servlet + jakarta.servlet-api + + + + org.springframework + spring-webmvc + + org.springframework.experimental.ai - spring-ai-openai - ${project.parent.version} - true + spring-ai-core + ${project.version} org.springframework.boot spring-boot-starter-test + + + com.squareup.okhttp3 + mockwebserver + - \ No newline at end of file + diff --git a/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiMockTestConfiguration.java b/spring-ai-test/src/main/java/org/springframework/ai/test/config/MockAiTestConfiguration.java similarity index 77% rename from spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiMockTestConfiguration.java rename to spring-ai-test/src/main/java/org/springframework/ai/test/config/MockAiTestConfiguration.java index 35f2cbafc..1ce3754a6 100644 --- a/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiMockTestConfiguration.java +++ b/spring-ai-test/src/main/java/org/springframework/ai/test/config/MockAiTestConfiguration.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.openai; +package org.springframework.ai.test.config; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -23,27 +23,18 @@ import java.io.UnsupportedEncodingException; import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.Optional; import java.util.Queue; -import java.util.UUID; import java.util.concurrent.ConcurrentLinkedDeque; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.theokanning.openai.client.OpenAiApi; -import com.theokanning.openai.service.OpenAiService; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.openai.client.OpenAiClient; -import org.springframework.ai.openai.metadata.support.OpenAiHttpResponseHeadersInterceptor; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; -import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.SmartLifecycle; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.Configuration; import org.springframework.lang.Nullable; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.web.servlet.MockMvc; @@ -53,26 +44,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; import okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import okio.Buffer; -import retrofit2.Retrofit; -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; -import retrofit2.converter.jackson.JacksonConverterFactory; /** - * {@link SpringBootConfiguration} for {@literal OpenAI's} API using mock objects. + * Spring {@link Configuration} for AI integration testing using mock objects. *

- * 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 { *

  • The MockWebServer.started is a private state variable
  • *
  • The overridden before() function is protected
  • *
  • The class is final and cannot be extended
  • - *
  • Calling MockWebServer.url(:String) needed to construct Retrofit client in the - * theoOpenAiService bean necessarily starts the MockWebServer
  • + *
  • Calling MockWebServer.url(:String) is needed to construct Retrofit client in + * the theoOpenAiService bean necessarily starts the MockWebServer
  • * *

    * 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 */