Fix OpenAI ChatResponse usage calculation when toolcalling is used
- Fix OpenAI ChatModel's call() operation
- When toolcalling is used, calculate cumulative usage from the preceding ChatResponses
- Fix OpenAI ChatModel's stream() operation
- Make sure that cumulative usage is calculated from the ChatResponse which has a valid usage
- Use overlapping buffer to check and store the usage from the response that holds the usage.
- Add tests for both call() and stream()
This commit is contained in:
committed by
Christian Tzolov
parent
ebc91680f8
commit
37cf20dfe4
@@ -42,6 +42,8 @@ import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
|
||||
import org.springframework.ai.chat.metadata.EmptyUsage;
|
||||
import org.springframework.ai.chat.metadata.RateLimit;
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
import org.springframework.ai.chat.metadata.UsageUtils;
|
||||
import org.springframework.ai.chat.model.AbstractToolCallSupport;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
@@ -99,6 +101,7 @@ import org.springframework.util.StringUtils;
|
||||
* @author Mariusz Bernacki
|
||||
* @author luocongqiu
|
||||
* @author Thomas Vitale
|
||||
* @author Ilayaperumal Gopinathan
|
||||
* @see ChatModel
|
||||
* @see StreamingChatModel
|
||||
* @see OpenAiApi
|
||||
@@ -215,6 +218,10 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
return this.internalCall(prompt, null);
|
||||
}
|
||||
|
||||
public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) {
|
||||
|
||||
ChatCompletionRequest request = createRequest(prompt, false);
|
||||
|
||||
@@ -259,8 +266,12 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
|
||||
// Non function calling.
|
||||
RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);
|
||||
|
||||
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody(), rateLimit));
|
||||
// Current usage
|
||||
OpenAiApi.Usage usage = completionEntity.getBody().usage();
|
||||
Usage currentChatResponseUsage = usage != null ? OpenAiUsage.from(usage) : new EmptyUsage();
|
||||
Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentChatResponseUsage, previousChatResponse);
|
||||
ChatResponse chatResponse = new ChatResponse(generations,
|
||||
from(completionEntity.getBody(), rateLimit, accumulatedUsage));
|
||||
|
||||
observationContext.setResponse(chatResponse);
|
||||
|
||||
@@ -274,7 +285,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
var toolCallConversation = handleToolCalls(prompt, response);
|
||||
// Recursively call the call method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
return this.internalCall(new Prompt(toolCallConversation, prompt.getOptions()), response);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -282,6 +293,10 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
return internalStream(prompt, null);
|
||||
}
|
||||
|
||||
public Flux<ChatResponse> internalStream(Prompt prompt, ChatResponse previousChatResponse) {
|
||||
return Flux.deferContextual(contextView -> {
|
||||
ChatCompletionRequest request = createRequest(prompt, true);
|
||||
|
||||
@@ -337,15 +352,43 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
return buildGeneration(choice, metadata, request);
|
||||
}).toList();
|
||||
// @formatter:on
|
||||
|
||||
return new ChatResponse(generations, from(chatCompletion2, null));
|
||||
OpenAiApi.Usage usage = chatCompletion2.usage();
|
||||
Usage currentChatResponseUsage = usage != null ? OpenAiUsage.from(usage) : new EmptyUsage();
|
||||
Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentChatResponseUsage,
|
||||
previousChatResponse);
|
||||
return new ChatResponse(generations, from(chatCompletion2, null, accumulatedUsage));
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Error processing chat completion", e);
|
||||
return new ChatResponse(List.of());
|
||||
}
|
||||
|
||||
}));
|
||||
// When in stream mode and enabled to include the usage, the OpenAI
|
||||
// Chat completion response would have the usage set only in its
|
||||
// final response. Hence, the following overlapping buffer is
|
||||
// created to store both the current and the subsequent response
|
||||
// to accumulate the usage from the subsequent response.
|
||||
}))
|
||||
.buffer(2, 1)
|
||||
.map(bufferList -> {
|
||||
ChatResponse firstResponse = bufferList.get(0);
|
||||
if (request.streamOptions() != null && request.streamOptions().includeUsage()) {
|
||||
if (bufferList.size() == 2) {
|
||||
ChatResponse secondResponse = bufferList.get(1);
|
||||
if (secondResponse != null && secondResponse.getMetadata() != null) {
|
||||
// This is the usage from the final Chat response for a
|
||||
// given Chat request.
|
||||
Usage usage = secondResponse.getMetadata().getUsage();
|
||||
if (!UsageUtils.isEmpty(usage)) {
|
||||
// Store the usage from the final response to the
|
||||
// penultimate response for accumulation.
|
||||
return new ChatResponse(firstResponse.getResults(),
|
||||
from(firstResponse.getMetadata(), usage));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstResponse;
|
||||
});
|
||||
|
||||
// @formatter:off
|
||||
Flux<ChatResponse> flux = chatResponse.flatMap(response -> {
|
||||
@@ -355,7 +398,7 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
var toolCallConversation = handleToolCalls(prompt, response);
|
||||
// Recursively call the stream method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.stream(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
return this.internalStream(new Prompt(toolCallConversation, prompt.getOptions()), response);
|
||||
}
|
||||
else {
|
||||
return Flux.just(response);
|
||||
@@ -417,11 +460,11 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
return new Generation(assistantMessage, generationMetadataBuilder.build());
|
||||
}
|
||||
|
||||
private ChatResponseMetadata from(OpenAiApi.ChatCompletion result, RateLimit rateLimit) {
|
||||
private ChatResponseMetadata from(OpenAiApi.ChatCompletion result, RateLimit rateLimit, Usage usage) {
|
||||
Assert.notNull(result, "OpenAI ChatCompletionResult must not be null");
|
||||
var builder = ChatResponseMetadata.builder()
|
||||
.withId(result.id() != null ? result.id() : "")
|
||||
.withUsage(result.usage() != null ? OpenAiUsage.from(result.usage()) : new EmptyUsage())
|
||||
.withUsage(usage)
|
||||
.withModel(result.model() != null ? result.model() : "")
|
||||
.withKeyValue("created", result.created() != null ? result.created() : 0L)
|
||||
.withKeyValue("system-fingerprint", result.systemFingerprint() != null ? result.systemFingerprint() : "");
|
||||
@@ -431,6 +474,18 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private ChatResponseMetadata from(ChatResponseMetadata chatResponseMetadata, Usage usage) {
|
||||
Assert.notNull(chatResponseMetadata, "OpenAI ChatResponseMetadata must not be null");
|
||||
var builder = ChatResponseMetadata.builder()
|
||||
.withId(chatResponseMetadata.getId() != null ? chatResponseMetadata.getId() : "")
|
||||
.withUsage(usage)
|
||||
.withModel(chatResponseMetadata.getModel() != null ? chatResponseMetadata.getModel() : "");
|
||||
if (chatResponseMetadata.getRateLimit() != null) {
|
||||
builder.withRateLimit(chatResponseMetadata.getRateLimit());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null.
|
||||
* @param chunk the ChatCompletionChunk to convert
|
||||
@@ -537,7 +592,6 @@ public class OpenAiChatModel extends AbstractToolCallSupport implements ChatMode
|
||||
OpenAiChatOptions.builder().withTools(this.getFunctionTools(enabledToolsToUse)).build(), request,
|
||||
ChatCompletionRequest.class);
|
||||
}
|
||||
|
||||
// Remove `streamOptions` from the request if it is not a streaming request
|
||||
if (request.streamOptions() != null && !stream) {
|
||||
logger.warn("Removing streamOptions from the request as it is not a streaming request!");
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.DefaultUsage;
|
||||
import org.springframework.ai.chat.metadata.EmptyUsage;
|
||||
import org.springframework.ai.chat.metadata.Usage;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
@@ -59,7 +60,6 @@ import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioPa
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters.AudioResponseFormat;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters.Voice;
|
||||
import org.springframework.ai.openai.api.tool.MockWeatherService;
|
||||
import org.springframework.ai.openai.metadata.OpenAiUsage;
|
||||
import org.springframework.ai.openai.testutils.AbstractIT;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -385,6 +385,35 @@ public class OpenAiChatModelIT extends AbstractIT {
|
||||
assertThat(content).containsAnyOf("15.0", "15");
|
||||
}
|
||||
|
||||
@Test
|
||||
void functionCallUsageTest() {
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = OpenAiChatOptions.builder()
|
||||
// .withModel(OpenAiApi.ChatModel.GPT_4_TURBO_PREVIEW.getValue())
|
||||
.withFunctionCallbacks(List.of(FunctionCallback.builder()
|
||||
.function("getCurrentWeather", new MockWeatherService())
|
||||
.description("Get the weather in location")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
ChatResponse chatResponse = this.chatModel.call(new Prompt(messages, promptOptions));
|
||||
logger.info("Response: {}", chatResponse);
|
||||
Usage usage = chatResponse.getMetadata().getUsage();
|
||||
|
||||
logger.info("Usage: {}", usage);
|
||||
assertThat(usage).isNotNull();
|
||||
assertThat(usage).isNotInstanceOf(EmptyUsage.class);
|
||||
assertThat(usage).isInstanceOf(DefaultUsage.class);
|
||||
assertThat(usage.getPromptTokens()).isGreaterThan(450L).isLessThan(600L);
|
||||
assertThat(usage.getGenerationTokens()).isGreaterThan(230L).isLessThan(360L);
|
||||
assertThat(usage.getTotalTokens()).isGreaterThan(680L).isLessThan(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamFunctionCallUsageTest() {
|
||||
|
||||
@@ -403,13 +432,15 @@ public class OpenAiChatModelIT extends AbstractIT {
|
||||
.build();
|
||||
|
||||
Flux<ChatResponse> response = this.streamingChatModel.stream(new Prompt(messages, promptOptions));
|
||||
|
||||
Usage usage = response.blockLast().getMetadata().getUsage();
|
||||
Usage usage = response.last().block().getMetadata().getUsage();
|
||||
|
||||
logger.info("Usage: {}", usage);
|
||||
assertThat(usage).isNotNull();
|
||||
assertThat(usage).isNotInstanceOf(EmptyUsage.class);
|
||||
assertThat(usage).isInstanceOf(OpenAiUsage.class);
|
||||
assertThat(usage).isInstanceOf(DefaultUsage.class);
|
||||
assertThat(usage.getPromptTokens()).isGreaterThan(450L).isLessThan(600L);
|
||||
assertThat(usage.getGenerationTokens()).isGreaterThan(230L).isLessThan(360L);
|
||||
assertThat(usage.getTotalTokens()).isGreaterThan(680L).isLessThan(960L);
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
|
||||
@@ -74,7 +74,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*/
|
||||
@SpringBootTest(classes = PerplexityWithOpenAiChatModelIT.Config.class)
|
||||
@EnabledIfEnvironmentVariable(named = "PERPLEXITY_API_KEY", matches = ".+")
|
||||
// @Disabled("Requires Perplexity credits")
|
||||
@Disabled("Requires Perplexity credits")
|
||||
class PerplexityWithOpenAiChatModelIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PerplexityWithOpenAiChatModelIT.class);
|
||||
@@ -187,6 +187,7 @@ class PerplexityWithOpenAiChatModelIT {
|
||||
|
||||
@Disabled()
|
||||
@Test
|
||||
@Disabled("Perplexity gets confused with the 'Generate the filmography for a random actor.' prompt")
|
||||
void beanOutputConverter() {
|
||||
BeanOutputConverter<ActorsFilms> outputConverter = new BeanOutputConverter<>(ActorsFilms.class);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2024-2024 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.chat.metadata;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
|
||||
/**
|
||||
* An utility class to provide support methods handling {@link Usage}.
|
||||
*
|
||||
* @author Ilayaperumal Gopinathan
|
||||
*/
|
||||
public class UsageUtils {
|
||||
|
||||
public static Usage getCumulativeUsage(final Usage currentUsage, final ChatResponse previousChatResponse) {
|
||||
Long promptTokens = currentUsage.getPromptTokens().longValue();
|
||||
Long generationTokens = currentUsage.getGenerationTokens().longValue();
|
||||
Long totalTokens = currentUsage.getTotalTokens().longValue();
|
||||
// Make sure to accumulate the usage from the previous chat response.
|
||||
if (previousChatResponse != null && previousChatResponse.getMetadata() != null
|
||||
&& previousChatResponse.getMetadata().getUsage() != null) {
|
||||
Usage usageFromPreviousChatResponse = previousChatResponse.getMetadata().getUsage();
|
||||
promptTokens += usageFromPreviousChatResponse.getPromptTokens();
|
||||
generationTokens += usageFromPreviousChatResponse.getGenerationTokens();
|
||||
totalTokens += usageFromPreviousChatResponse.getTotalTokens();
|
||||
}
|
||||
return new DefaultUsage(promptTokens, generationTokens, totalTokens);
|
||||
}
|
||||
|
||||
public static boolean isEmpty(Usage usage) {
|
||||
if (usage == null) {
|
||||
return true;
|
||||
}
|
||||
else if (usage != null && usage.getTotalTokens() == 0L) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user