diff --git a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java index 32a09a85425..23970593bc9 100644 --- a/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java +++ b/auto-configurations/models/chat/client/spring-ai-autoconfigure-model-chat-client/src/main/java/org/springframework/ai/model/chat/client/autoconfigure/ChatClientAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClientCustomizer; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationContext; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientPromptContentObservationHandler; @@ -84,11 +85,12 @@ ChatClientBuilderConfigurer chatClientBuilderConfigurer(ObjectProvider observationRegistry, - ObjectProvider observationConvention) { - + ObjectProvider chatClientObservationConvention, + ObjectProvider advisorObservationConvention) { ChatClient.Builder builder = ChatClient.builder(chatModel, observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP), - observationConvention.getIfUnique(() -> null)); + chatClientObservationConvention.getIfUnique(() -> null), + advisorObservationConvention.getIfUnique(() -> null)); return chatClientBuilderConfigurer.configure(builder); } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java index 3b445aa0d14..0cf14018ad0 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -26,6 +26,7 @@ import reactor.core.publisher.Flux; import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.model.ChatModel; @@ -65,22 +66,46 @@ static ChatClient create(ChatModel chatModel, ObservationRegistry observationReg return create(chatModel, observationRegistry, null); } + /** + * @deprecated in favor of + * {@link #create(ChatModel, ObservationRegistry, ChatClientObservationConvention, AdvisorObservationConvention)}. + */ + @Deprecated(since = "1.1.0", forRemoval = true) + static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry, + @Nullable ChatClientObservationConvention chatClientObservationConvention) { + return create(chatModel, observationRegistry, chatClientObservationConvention, null); + } + static ChatClient create(ChatModel chatModel, ObservationRegistry observationRegistry, - @Nullable ChatClientObservationConvention observationConvention) { + @Nullable ChatClientObservationConvention chatClientObservationConvention, + @Nullable AdvisorObservationConvention advisorObservationConvention) { Assert.notNull(chatModel, "chatModel cannot be null"); Assert.notNull(observationRegistry, "observationRegistry cannot be null"); - return builder(chatModel, observationRegistry, observationConvention).build(); + return builder(chatModel, observationRegistry, chatClientObservationConvention, advisorObservationConvention) + .build(); } static Builder builder(ChatModel chatModel) { return builder(chatModel, ObservationRegistry.NOOP, null); } + /** + * @deprecated in favor of + * {@link #builder(ChatModel, ObservationRegistry, ChatClientObservationConvention, AdvisorObservationConvention)}. + */ + @Deprecated(since = "1.1.0", forRemoval = true) + static Builder builder(ChatModel chatModel, ObservationRegistry observationRegistry, + @Nullable ChatClientObservationConvention chatClientObservationConvention) { + return builder(chatModel, observationRegistry, chatClientObservationConvention, null); + } + static Builder builder(ChatModel chatModel, ObservationRegistry observationRegistry, - @Nullable ChatClientObservationConvention customObservationConvention) { + @Nullable ChatClientObservationConvention chatClientObservationConvention, + @Nullable AdvisorObservationConvention advisorObservationConvention) { Assert.notNull(chatModel, "chatModel cannot be null"); Assert.notNull(observationRegistry, "observationRegistry cannot be null"); - return new DefaultChatClientBuilder(chatModel, observationRegistry, customObservationConvention); + return new DefaultChatClientBuilder(chatModel, observationRegistry, chatClientObservationConvention, + advisorObservationConvention); } ChatClientRequestSpec prompt(); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 20b207d5c5c..772b3a41b2a 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -38,6 +38,7 @@ import org.springframework.ai.chat.client.advisor.DefaultAroundAdvisorChain; import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.chat.client.advisor.api.BaseAdvisorChain; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationContext; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationDocumentation; @@ -513,7 +514,9 @@ private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest c // CHECKSTYLE:OFF var chatClientResponse = observation.observe(() -> { // Apply the advisor chain that terminates with the ChatModelCallAdvisor. - return this.advisorChain.nextCall(chatClientRequest); + var response = this.advisorChain.nextCall(chatClientRequest); + observationContext.setChatClientResponse(response); + return response; }); // CHECKSTYLE:ON return chatClientResponse != null ? chatClientResponse : ChatClientResponse.builder().build(); @@ -608,7 +611,10 @@ public static class DefaultChatClientRequestSpec implements ChatClientRequestSpe private final ObservationRegistry observationRegistry; - private final ChatClientObservationConvention observationConvention; + private final ChatClientObservationConvention chatClientObservationConvention; + + @Nullable + private final AdvisorObservationConvention advisorObservationConvention; private final ChatModel chatModel; @@ -649,18 +655,34 @@ public static class DefaultChatClientRequestSpec implements ChatClientRequestSpe DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) { this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.userMetadata, ccr.systemText, ccr.systemParams, ccr.systemMetadata, ccr.toolCallbacks, ccr.messages, ccr.toolNames, ccr.media, ccr.chatOptions, - ccr.advisors, ccr.advisorParams, ccr.observationRegistry, ccr.observationConvention, - ccr.toolContext, ccr.templateRenderer); + ccr.advisors, ccr.advisorParams, ccr.observationRegistry, ccr.chatClientObservationConvention, + ccr.toolContext, ccr.templateRenderer, ccr.advisorObservationConvention); } + /** + * @deprecated in favor of the other constructor. + */ + @Deprecated(since = "1.1.0", forRemoval = true) public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userText, Map userParams, Map userMetadata, @Nullable String systemText, Map systemParams, Map systemMetadata, List toolCallbacks, List messages, List toolNames, List media, @Nullable ChatOptions chatOptions, List advisors, Map advisorParams, ObservationRegistry observationRegistry, - @Nullable ChatClientObservationConvention observationConvention, Map toolContext, - @Nullable TemplateRenderer templateRenderer) { + @Nullable ChatClientObservationConvention chatClientObservationConvention, + Map toolContext, @Nullable TemplateRenderer templateRenderer) { + this(chatModel, userText, userParams, userMetadata, systemText, systemParams, systemMetadata, toolCallbacks, + messages, toolNames, media, chatOptions, advisors, advisorParams, observationRegistry, + chatClientObservationConvention, toolContext, templateRenderer, null); + } + public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userText, + Map userParams, Map userMetadata, @Nullable String systemText, + Map systemParams, Map systemMetadata, List toolCallbacks, + List messages, List toolNames, List media, @Nullable ChatOptions chatOptions, + List advisors, Map advisorParams, ObservationRegistry observationRegistry, + @Nullable ChatClientObservationConvention chatClientObservationConvention, + Map toolContext, @Nullable TemplateRenderer templateRenderer, + @Nullable AdvisorObservationConvention advisorObservationConvention) { Assert.notNull(chatModel, "chatModel cannot be null"); Assert.notNull(userParams, "userParams cannot be null"); Assert.notNull(userMetadata, "userMetadata cannot be null"); @@ -694,10 +716,11 @@ public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userTe this.advisors.addAll(advisors); this.advisorParams.putAll(advisorParams); this.observationRegistry = observationRegistry; - this.observationConvention = observationConvention != null ? observationConvention - : DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION; + this.chatClientObservationConvention = chatClientObservationConvention != null + ? chatClientObservationConvention : DEFAULT_CHAT_CLIENT_OBSERVATION_CONVENTION; this.toolContext.putAll(toolContext); this.templateRenderer = templateRenderer != null ? templateRenderer : DEFAULT_TEMPLATE_RENDERER; + this.advisorObservationConvention = advisorObservationConvention; } @Nullable @@ -770,7 +793,8 @@ public TemplateRenderer getTemplateRenderer() { @Override public Builder mutate() { DefaultChatClientBuilder builder = (DefaultChatClientBuilder) ChatClient - .builder(this.chatModel, this.observationRegistry, this.observationConvention) + .builder(this.chatModel, this.observationRegistry, this.chatClientObservationConvention, + this.advisorObservationConvention) .defaultTemplateRenderer(this.templateRenderer) .defaultToolCallbacks(this.toolCallbacks) .defaultToolContext(this.toolContext) @@ -990,14 +1014,14 @@ public ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer) public CallResponseSpec call() { BaseAdvisorChain advisorChain = buildAdvisorChain(); return new DefaultCallResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain, - this.observationRegistry, this.observationConvention); + this.observationRegistry, this.chatClientObservationConvention); } @Override public StreamResponseSpec stream() { BaseAdvisorChain advisorChain = buildAdvisorChain(); return new DefaultStreamResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain, - this.observationRegistry, this.observationConvention); + this.observationRegistry, this.chatClientObservationConvention); } private BaseAdvisorChain buildAdvisorChain() { @@ -1006,7 +1030,10 @@ private BaseAdvisorChain buildAdvisorChain() { this.advisors.add(ChatModelCallAdvisor.builder().chatModel(this.chatModel).build()); this.advisors.add(ChatModelStreamAdvisor.builder().chatModel(this.chatModel).build()); - return DefaultAroundAdvisorChain.builder(this.observationRegistry).pushAll(this.advisors).build(); + return DefaultAroundAdvisorChain.builder(this.observationRegistry) + .observationConvention(this.advisorObservationConvention) + .pushAll(this.advisors) + .build(); } } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index a937356e543..05e117f4469 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -29,6 +29,7 @@ import org.springframework.ai.chat.client.ChatClient.PromptUserSpec; import org.springframework.ai.chat.client.DefaultChatClient.DefaultChatClientRequestSpec; import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.model.ChatModel; @@ -60,13 +61,24 @@ public class DefaultChatClientBuilder implements Builder { this(chatModel, ObservationRegistry.NOOP, null); } + /** + * @deprecated in favor of + * {@link #DefaultChatClientBuilder(ChatModel, ObservationRegistry, ChatClientObservationConvention, AdvisorObservationConvention)}. + */ + @Deprecated(since = "1.1.0", forRemoval = true) public DefaultChatClientBuilder(ChatModel chatModel, ObservationRegistry observationRegistry, - @Nullable ChatClientObservationConvention customObservationConvention) { + @Nullable ChatClientObservationConvention chatClientObservationConvention) { + this(chatModel, observationRegistry, chatClientObservationConvention, null); + } + + public DefaultChatClientBuilder(ChatModel chatModel, ObservationRegistry observationRegistry, + @Nullable ChatClientObservationConvention chatClientObservationConvention, + @Nullable AdvisorObservationConvention advisorObservationConvention) { Assert.notNull(chatModel, "the " + ChatModel.class.getName() + " must be non-null"); Assert.notNull(observationRegistry, "the " + ObservationRegistry.class.getName() + " must be non-null"); this.defaultRequest = new DefaultChatClientRequestSpec(chatModel, null, Map.of(), Map.of(), null, Map.of(), Map.of(), List.of(), List.of(), List.of(), List.of(), null, List.of(), Map.of(), observationRegistry, - customObservationConvention, Map.of(), null); + chatClientObservationConvention, Map.of(), null, advisorObservationConvention); } public ChatClient build() { diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java index a1c6393a6f9..62d80e13d1b 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java @@ -35,9 +35,8 @@ import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention; import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationDocumentation; import org.springframework.ai.chat.client.advisor.observation.DefaultAdvisorObservationConvention; -import org.springframework.ai.template.TemplateRenderer; -import org.springframework.ai.template.st.StTemplateRenderer; import org.springframework.core.OrderComparator; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -55,8 +54,6 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { public static final AdvisorObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultAdvisorObservationConvention(); - private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build(); - private final List originalCallAdvisors; private final List originalStreamAdvisors; @@ -67,8 +64,10 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { private final ObservationRegistry observationRegistry; + private final AdvisorObservationConvention observationConvention; + DefaultAroundAdvisorChain(ObservationRegistry observationRegistry, Deque callAdvisors, - Deque streamAdvisors) { + Deque streamAdvisors, @Nullable AdvisorObservationConvention observationConvention) { Assert.notNull(observationRegistry, "the observationRegistry must be non-null"); Assert.notNull(callAdvisors, "the callAdvisors must be non-null"); @@ -79,6 +78,8 @@ public class DefaultAroundAdvisorChain implements BaseAdvisorChain { this.streamAdvisors = streamAdvisors; this.originalCallAdvisors = List.copyOf(callAdvisors); this.originalStreamAdvisors = List.copyOf(streamAdvisors); + this.observationConvention = observationConvention != null ? observationConvention + : DEFAULT_OBSERVATION_CONVENTION; } public static Builder builder(ObservationRegistry observationRegistry) { @@ -102,8 +103,13 @@ public ChatClientResponse nextCall(ChatClientRequest chatClientRequest) { .build(); return AdvisorObservationDocumentation.AI_ADVISOR - .observation(null, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry) - .observe(() -> advisor.adviseCall(chatClientRequest, this)); + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + var chatClientResponse = advisor.adviseCall(chatClientRequest, this); + observationContext.setChatClientResponse(chatClientResponse); + return chatClientResponse; + }); } @Override @@ -123,7 +129,7 @@ public Flux nextStream(ChatClientRequest chatClientRequest) .order(advisor.getOrder()) .build(); - var observation = AdvisorObservationDocumentation.AI_ADVISOR.observation(null, + var observation = AdvisorObservationDocumentation.AI_ADVISOR.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry); observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); @@ -160,12 +166,20 @@ public static class Builder { private final Deque streamAdvisors; + @Nullable + private AdvisorObservationConvention observationConvention; + public Builder(ObservationRegistry observationRegistry) { this.observationRegistry = observationRegistry; this.callAdvisors = new ConcurrentLinkedDeque<>(); this.streamAdvisors = new ConcurrentLinkedDeque<>(); } + public Builder observationConvention(@Nullable AdvisorObservationConvention observationConvention) { + this.observationConvention = observationConvention; + return this; + } + public Builder push(Advisor advisor) { Assert.notNull(advisor, "the advisor must be non-null"); return this.pushAll(List.of(advisor)); @@ -214,7 +228,8 @@ private void reOrder() { } public DefaultAroundAdvisorChain build() { - return new DefaultAroundAdvisorChain(this.observationRegistry, this.callAdvisors, this.streamAdvisors); + return new DefaultAroundAdvisorChain(this.observationRegistry, this.callAdvisors, this.streamAdvisors, + this.observationConvention); } } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java index df750398a0f..3af9dd1b020 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/observation/ChatClientObservationContext.java @@ -22,6 +22,7 @@ import org.springframework.ai.chat.client.ChatClientAttributes; import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; import org.springframework.ai.chat.client.advisor.api.Advisor; import org.springframework.ai.observation.AiOperationMetadata; import org.springframework.ai.observation.conventions.AiOperationType; @@ -48,6 +49,9 @@ public class ChatClientObservationContext extends Observation.Context { private final boolean stream; + @Nullable + private ChatClientResponse chatClientResponse; + ChatClientObservationContext(ChatClientRequest chatClientRequest, List advisors, boolean isStream) { Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); @@ -78,6 +82,15 @@ public boolean isStream() { return this.stream; } + @Nullable + public ChatClientResponse getChatClientResponse() { + return this.chatClientResponse; + } + + public void setChatClientResponse(@Nullable ChatClientResponse chatClientResponse) { + this.chatClientResponse = chatClientResponse; + } + @Nullable public String getFormat() { if (this.request.context().get(ChatClientAttributes.OUTPUT_FORMAT.getKey()) instanceof String format) { diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java index 6fcde4557ea..d302ebf26f7 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/DefaultChatClientBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-2025 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. @@ -18,6 +18,7 @@ import java.nio.charset.Charset; +import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.model.ChatModel; @@ -63,6 +64,12 @@ void whenObservationRegistryIsNullThenThrows() { .hasMessage("the io.micrometer.observation.ObservationRegistry must be non-null"); } + @Test + void whenAdvisorObservationConventionIsNullThenReturn() { + var builder = new DefaultChatClientBuilder(mock(ChatModel.class), mock(ObservationRegistry.class), null, null); + assertThat(builder).isNotNull(); + } + @Test void whenUserResourceIsNullThenThrows() { DefaultChatClientBuilder builder = new DefaultChatClientBuilder(mock(ChatModel.class)); diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java index ed00537f716..50fc1b8a060 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChainTests.java @@ -76,6 +76,14 @@ void whenAdvisorListContainsNullElementsThenThrow() { .hasMessage("the advisors must not contain null elements"); } + @Test + void getObservationConventionIsNullThenUseDefault() { + AdvisorChain chain = DefaultAroundAdvisorChain.builder(ObservationRegistry.create()) + .observationConvention(null) + .build(); + assertThat(chain).isNotNull(); + } + @Test void getObservationRegistry() { ObservationRegistry observationRegistry = ObservationRegistry.create();