Skip to content

Commit dcc8d5b

Browse files
apappascstzolov
authored andcommitted
feat: Add Perplexity AI integration and documentation updates
- Introduced `PerplexityWithOpenAiChatModelIT` integration test for Perplexity AI with OpenAI Chat Model. - Includes various test cases for role-based prompts, streaming responses, token usage validation, and output converters. - Added tests for function calls and metadata validation. - Updated Antora navigation (`nav.adoc`) to include Perplexity AI documentation link. - Enhanced chat model comparison documentation to highlight Perplexity AI integration. - Added a dedicated `perplexity-chat.adoc` page under `spring-ai-docs` to provide detailed documentation for integrating Perplexity AI. - Covers API prerequisites, auto-configuration, and runtime options. - Explains configuration properties such as `spring.ai.openai.base-url`, `spring.ai.openai.chat.model`, and `spring.ai.openai.chat.options.*`. - Provides examples for environment variable setup and runtime overrides. - Highlights limitations like lack of multimodal support and explicit function calling. - Includes a sample Spring Boot controller demonstrating integration usage. - Links to Perplexity documentation for further reference.
1 parent 0b00e6f commit dcc8d5b

File tree

5 files changed

+560
-0
lines changed

5 files changed

+560
-0
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
/*
2+
* Copyright 2024 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.openai.chat.proxy;
18+
19+
import java.util.ArrayList;
20+
import java.util.Arrays;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.stream.Collectors;
24+
25+
import org.junit.jupiter.api.Disabled;
26+
import org.junit.jupiter.api.Test;
27+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
30+
import reactor.core.publisher.Flux;
31+
32+
import org.springframework.ai.chat.client.ChatClient;
33+
import org.springframework.ai.chat.messages.AssistantMessage;
34+
import org.springframework.ai.chat.messages.Message;
35+
import org.springframework.ai.chat.messages.UserMessage;
36+
import org.springframework.ai.chat.model.ChatResponse;
37+
import org.springframework.ai.chat.model.Generation;
38+
import org.springframework.ai.chat.prompt.Prompt;
39+
import org.springframework.ai.chat.prompt.PromptTemplate;
40+
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
41+
import org.springframework.ai.converter.BeanOutputConverter;
42+
import org.springframework.ai.converter.ListOutputConverter;
43+
import org.springframework.ai.converter.MapOutputConverter;
44+
import org.springframework.ai.model.function.FunctionCallback;
45+
import org.springframework.ai.openai.OpenAiChatModel;
46+
import org.springframework.ai.openai.OpenAiChatOptions;
47+
import org.springframework.ai.openai.api.OpenAiApi;
48+
import org.springframework.ai.openai.api.tool.MockWeatherService;
49+
import org.springframework.ai.openai.chat.ActorsFilms;
50+
import org.springframework.ai.retry.RetryUtils;
51+
import org.springframework.beans.factory.annotation.Autowired;
52+
import org.springframework.beans.factory.annotation.Value;
53+
import org.springframework.boot.SpringBootConfiguration;
54+
import org.springframework.boot.test.context.SpringBootTest;
55+
import org.springframework.context.annotation.Bean;
56+
import org.springframework.core.convert.support.DefaultConversionService;
57+
import org.springframework.core.io.Resource;
58+
import org.springframework.web.client.RestClient;
59+
import org.springframework.web.reactive.function.client.WebClient;
60+
61+
import static org.assertj.core.api.Assertions.assertThat;
62+
63+
/**
64+
* @author Alexandros Pappas
65+
*
66+
* Unlike other proxy implementations (e.g., NVIDIA), Perplexity operates differently:
67+
*
68+
* - Perplexity includes integrated real-time web search results as part of its response
69+
* rather than through explicit function calls. Consequently, no `toolCalls` or function
70+
* call mechanisms are exposed in the API responses
71+
*
72+
* For more information on Perplexity's behavior, refer to its API documentation:
73+
* <a href="https://docs.perplexity.ai/api-reference/chat-completions">perplexity-api</a>
74+
*/
75+
@SpringBootTest(classes = PerplexityWithOpenAiChatModelIT.Config.class)
76+
@EnabledIfEnvironmentVariable(named = "PERPLEXITY_API_KEY", matches = ".+")
77+
// @Disabled("Requires Perplexity credits")
78+
class PerplexityWithOpenAiChatModelIT {
79+
80+
private static final Logger logger = LoggerFactory.getLogger(PerplexityWithOpenAiChatModelIT.class);
81+
82+
private static final String PERPLEXITY_BASE_URL = "https://api.perplexity.ai";
83+
84+
private static final String PERPLEXITY_COMPLETIONS_PATH = "/chat/completions";
85+
86+
private static final String DEFAULT_PERPLEXITY_MODEL = "llama-3.1-sonar-small-128k-online";
87+
88+
@Value("classpath:/prompts/system-message.st")
89+
private Resource systemResource;
90+
91+
@Autowired
92+
private OpenAiChatModel chatModel;
93+
94+
@Test
95+
void roleTest() {
96+
// Ensure the SystemMessage comes before UserMessage to comply with Perplexity
97+
// API's sequence rules
98+
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource);
99+
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
100+
UserMessage userMessage = new UserMessage(
101+
"Tell me about 3 famous pirates from the Golden Age of Piracy and what they did.");
102+
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
103+
ChatResponse response = this.chatModel.call(prompt);
104+
assertThat(response.getResults()).hasSize(1);
105+
assertThat(response.getResults().get(0).getOutput().getContent()).contains("Blackbeard");
106+
}
107+
108+
@Test
109+
void streamRoleTest() {
110+
// Ensure the SystemMessage comes before UserMessage to comply with Perplexity
111+
// API's sequence rules
112+
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(this.systemResource);
113+
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
114+
UserMessage userMessage = new UserMessage(
115+
"Tell me about 3 famous pirates from the Golden Age of Piracy and what they did.");
116+
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
117+
Flux<ChatResponse> flux = this.chatModel.stream(prompt);
118+
119+
List<ChatResponse> responses = flux.collectList().block();
120+
assertThat(responses.size()).isGreaterThan(1);
121+
122+
String stitchedResponseContent = responses.stream()
123+
.map(ChatResponse::getResults)
124+
.flatMap(List::stream)
125+
.map(Generation::getOutput)
126+
.map(AssistantMessage::getContent)
127+
.collect(Collectors.joining());
128+
129+
assertThat(stitchedResponseContent).contains("Blackbeard");
130+
}
131+
132+
@Test
133+
void streamingWithTokenUsage() {
134+
var promptOptions = OpenAiChatOptions.builder().withStreamUsage(true).withSeed(1).build();
135+
136+
var prompt = new Prompt("List two colors of the Polish flag. Be brief.", promptOptions);
137+
138+
var streamingTokenUsage = this.chatModel.stream(prompt).blockLast().getMetadata().getUsage();
139+
var referenceTokenUsage = this.chatModel.call(prompt).getMetadata().getUsage();
140+
141+
assertThat(streamingTokenUsage.getPromptTokens()).isGreaterThan(0);
142+
assertThat(streamingTokenUsage.getGenerationTokens()).isGreaterThan(0);
143+
assertThat(streamingTokenUsage.getTotalTokens()).isGreaterThan(0);
144+
145+
assertThat(streamingTokenUsage.getPromptTokens()).isEqualTo(referenceTokenUsage.getPromptTokens());
146+
assertThat(streamingTokenUsage.getGenerationTokens()).isEqualTo(referenceTokenUsage.getGenerationTokens());
147+
assertThat(streamingTokenUsage.getTotalTokens()).isEqualTo(referenceTokenUsage.getTotalTokens());
148+
}
149+
150+
@Test
151+
void listOutputConverter() {
152+
DefaultConversionService conversionService = new DefaultConversionService();
153+
ListOutputConverter outputConverter = new ListOutputConverter(conversionService);
154+
155+
String format = outputConverter.getFormat();
156+
String template = """
157+
List five {subject}
158+
{format}
159+
""";
160+
PromptTemplate promptTemplate = new PromptTemplate(template,
161+
Map.of("subject", "ice cream flavors", "format", format));
162+
Prompt prompt = new Prompt(promptTemplate.createMessage());
163+
Generation generation = this.chatModel.call(prompt).getResult();
164+
165+
List<String> list = outputConverter.convert(generation.getOutput().getContent());
166+
assertThat(list).hasSize(5);
167+
}
168+
169+
@Test
170+
void mapOutputConverter() {
171+
MapOutputConverter outputConverter = new MapOutputConverter();
172+
173+
String format = outputConverter.getFormat();
174+
String template = """
175+
Provide me a List of {subject}
176+
{format}
177+
""";
178+
PromptTemplate promptTemplate = new PromptTemplate(template,
179+
Map.of("subject", "numbers from 1 to 9 under the key name 'numbers'", "format", format));
180+
Prompt prompt = new Prompt(promptTemplate.createMessage());
181+
Generation generation = this.chatModel.call(prompt).getResult();
182+
183+
Map<String, Object> result = outputConverter.convert(generation.getOutput().getContent());
184+
assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
185+
}
186+
187+
@Test
188+
void beanOutputConverter() {
189+
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
190+
191+
String format = outputConverter.getFormat();
192+
String template = """
193+
Generate the filmography for a random actor.
194+
{format}
195+
""";
196+
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
197+
Prompt prompt = new Prompt(promptTemplate.createMessage());
198+
Generation generation = this.chatModel.call(prompt).getResult();
199+
200+
ActorsFilms actorsFilms = outputConverter.convert(generation.getOutput().getContent());
201+
assertThat(actorsFilms.getActor()).isNotEmpty();
202+
}
203+
204+
@Test
205+
void beanOutputConverterRecords() {
206+
BeanOutputConverter<ActorsFilmsRecord> outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class);
207+
208+
String format = outputConverter.getFormat();
209+
String template = """
210+
Generate the filmography of 5 movies for Tom Hanks.
211+
{format}
212+
""";
213+
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
214+
Prompt prompt = new Prompt(promptTemplate.createMessage());
215+
Generation generation = this.chatModel.call(prompt).getResult();
216+
217+
ActorsFilmsRecord actorsFilms = outputConverter.convert(generation.getOutput().getContent());
218+
logger.info("" + actorsFilms);
219+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
220+
assertThat(actorsFilms.movies()).hasSize(5);
221+
}
222+
223+
@Test
224+
void beanStreamOutputConverterRecords() {
225+
BeanOutputConverter<ActorsFilmsRecord> outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class);
226+
227+
String format = outputConverter.getFormat();
228+
String template = """
229+
Generate the filmography of 5 movies for Tom Hanks.
230+
{format}
231+
""";
232+
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
233+
Prompt prompt = new Prompt(promptTemplate.createMessage());
234+
235+
String generationTextFromStream = this.chatModel.stream(prompt)
236+
.collectList()
237+
.block()
238+
.stream()
239+
.map(ChatResponse::getResults)
240+
.flatMap(List::stream)
241+
.map(Generation::getOutput)
242+
.map(AssistantMessage::getContent)
243+
.filter(c -> c != null)
244+
.collect(Collectors.joining());
245+
246+
ActorsFilmsRecord actorsFilms = outputConverter.convert(generationTextFromStream);
247+
logger.info("" + actorsFilms);
248+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
249+
assertThat(actorsFilms.movies()).hasSize(5);
250+
}
251+
252+
@Test
253+
void functionCallTest() {
254+
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
255+
256+
List<Message> messages = new ArrayList<>(List.of(userMessage));
257+
258+
var promptOptions = OpenAiChatOptions.builder()
259+
.withFunctionCallbacks(List.of(FunctionCallback.builder()
260+
.description("Get the weather in location")
261+
.function("getCurrentWeather", new MockWeatherService())
262+
.inputType(MockWeatherService.Request.class)
263+
.build()))
264+
.build();
265+
266+
ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions));
267+
268+
logger.info("Response: {}", response);
269+
270+
assertThat(response.getResults().stream().mapToLong(r -> r.getOutput().getToolCalls().size()).sum()).isZero();
271+
}
272+
273+
@Test
274+
void streamFunctionCallTest() {
275+
UserMessage userMessage = new UserMessage(
276+
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
277+
278+
List<Message> messages = new ArrayList<>(List.of(userMessage));
279+
280+
var promptOptions = OpenAiChatOptions.builder()
281+
.withFunctionCallbacks(List.of(FunctionCallback.builder()
282+
.description("Get the weather in location")
283+
.function("getCurrentWeather", new MockWeatherService())
284+
.inputType(MockWeatherService.Request.class)
285+
.build()))
286+
.build();
287+
288+
Flux<ChatResponse> response = this.chatModel.stream(new Prompt(messages, promptOptions));
289+
290+
String content = response.collectList()
291+
.block()
292+
.stream()
293+
.map(ChatResponse::getResults)
294+
.flatMap(List::stream)
295+
.map(Generation::getOutput)
296+
.map(AssistantMessage::getContent)
297+
.collect(Collectors.joining());
298+
logger.info("Response: {}", content);
299+
300+
assertThat(content).doesNotContain("toolCalls");
301+
}
302+
303+
@Test
304+
void validateCallResponseMetadata() {
305+
ChatResponse response = ChatClient.create(this.chatModel)
306+
.prompt()
307+
.options(OpenAiChatOptions.builder().withModel(DEFAULT_PERPLEXITY_MODEL).build())
308+
.user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did")
309+
.call()
310+
.chatResponse();
311+
312+
logger.info(response.toString());
313+
assertThat(response.getMetadata().getId()).isNotEmpty();
314+
assertThat(response.getMetadata().getModel()).containsIgnoringCase(DEFAULT_PERPLEXITY_MODEL);
315+
assertThat(response.getMetadata().getUsage().getPromptTokens()).isPositive();
316+
assertThat(response.getMetadata().getUsage().getGenerationTokens()).isPositive();
317+
assertThat(response.getMetadata().getUsage().getTotalTokens()).isPositive();
318+
}
319+
320+
record ActorsFilmsRecord(String actor, List<String> movies) {
321+
}
322+
323+
@SpringBootConfiguration
324+
static class Config {
325+
326+
@Bean
327+
public OpenAiApi chatCompletionApi() {
328+
return new OpenAiApi(PERPLEXITY_BASE_URL, System.getenv("PERPLEXITY_API_KEY"), PERPLEXITY_COMPLETIONS_PATH,
329+
"/v1/embeddings", RestClient.builder(), WebClient.builder(),
330+
RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
331+
}
332+
333+
@Bean
334+
public OpenAiChatModel openAiClient(OpenAiApi openAiApi) {
335+
return new OpenAiChatModel(openAiApi,
336+
OpenAiChatOptions.builder().withModel(DEFAULT_PERPLEXITY_MODEL).build());
337+
}
338+
339+
}
340+
341+
}
269 KB
Loading

spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
//// **** xref:api/chat/functions/moonshot-chat-functions.adoc[Function Calling]
3232
*** xref:api/chat/nvidia-chat.adoc[NVIDIA]
3333
*** xref:api/chat/ollama-chat.adoc[Ollama]
34+
*** xref:api/chat/perplexity-chat.adoc[Perplexity AI]
3435
*** OCI Generative AI
3536
**** xref:api/chat/oci-genai/cohere-chat.adoc[Cohere]
3637
*** xref:api/chat/openai-chat.adoc[OpenAI]

spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/comparison.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This table compares various Chat Models supported by Spring AI, detailing their
3131
| xref::api/chat/oci-genai/cohere-chat.adoc[OCI GenAI/Cohere] | text ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12]
3232
| xref::api/chat/ollama-chat.adoc[Ollama] | text, image ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16]
3333
| xref::api/chat/openai-chat.adoc[OpenAI] | text, image, audio ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16]
34+
| xref::api/chat/perplexity-chat.adoc[Perplexity (OpenAI-proxy)] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16]
3435
| xref::api/chat/qianfan-chat.adoc[QianFan] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12]
3536
| xref::api/chat/zhipuai-chat.adoc[ZhiPu AI] | text ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12]
3637
| xref::api/chat/watsonx-ai-chat.adoc[Watsonx.AI] | text ^a| image::no.svg[width=12] ^a| image::yes.svg[width=16] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12] ^a| image::no.svg[width=12]

0 commit comments

Comments
 (0)