diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeChatFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeChatFormatter.java index b84e739b9..8c244c537 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeChatFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeChatFormatter.java @@ -16,6 +16,7 @@ package io.agentscope.core.formatter.dashscope; import io.agentscope.core.formatter.AbstractBaseFormatter; +import io.agentscope.core.formatter.dashscope.dto.DashScopeContentPart; import io.agentscope.core.formatter.dashscope.dto.DashScopeInput; import io.agentscope.core.formatter.dashscope.dto.DashScopeMessage; import io.agentscope.core.formatter.dashscope.dto.DashScopeParameters; @@ -173,11 +174,15 @@ public DashScopeRequest buildRequest( } /** - * Apply cache control to DashScope messages. + * Apply cache control to DashScope messages at the content block level. * - *

Adds cache_control: {"type": "ephemeral"} to all system messages and the last - * message in the list. Messages that already have cache_control set (e.g., via manual metadata - * marking) will not be overwritten. + *

Per the DashScope API specification, {@code cache_control} must be placed inside content + * blocks (within the {@code content} array), not at the message level. This method converts + * string content to array format when needed and sets {@code cache_control} on the last content + * block of each target message. + * + *

Target messages: all system messages and the last message in the list. Messages whose last + * content block already has {@code cache_control} set will not be overwritten. * * @param messages the list of formatted DashScope messages */ @@ -186,14 +191,48 @@ public void applyCacheControl(List messages) { return; } for (DashScopeMessage msg : messages) { - if ("system".equals(msg.getRole()) && msg.getCacheControl() == null) { - msg.setCacheControl(EPHEMERAL_CACHE_CONTROL); + if ("system".equals(msg.getRole())) { + applyCacheControlToContentBlock(msg); } } DashScopeMessage lastMsg = messages.get(messages.size() - 1); - if (lastMsg.getCacheControl() == null) { - lastMsg.setCacheControl(EPHEMERAL_CACHE_CONTROL); + applyCacheControlToContentBlock(lastMsg); + } + + /** + * Apply ephemeral cache_control to the last content block of the given message. + * If content is a plain string, it is first converted to array format. + * Skips if the last content block already has cache_control set. + */ + static void applyCacheControlToContentBlock(DashScopeMessage msg) { + List parts = ensureContentArray(msg); + if (parts.isEmpty()) { + return; + } + DashScopeContentPart lastPart = parts.get(parts.size() - 1); + if (lastPart.getCacheControl() == null) { + lastPart.setCacheControl(EPHEMERAL_CACHE_CONTROL); + } + } + + /** + * Ensure the message content is in array format ({@code List}). + * If content is a plain string, converts it to {@code [{"type":"text","text":"..."}]}. + * + * @return the content part list (never null, may be empty) + */ + @SuppressWarnings("unchecked") + static List ensureContentArray(DashScopeMessage msg) { + Object content = msg.getContent(); + if (content instanceof List) { + return (List) content; + } + List parts = new ArrayList<>(); + if (content instanceof String text) { + parts.add(DashScopeContentPart.text(text)); } + msg.setContent(parts); + return parts; } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMessageConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMessageConverter.java index 31a044c5e..f536f72b6 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMessageConverter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMessageConverter.java @@ -249,7 +249,9 @@ private String extractTextContent(Msg msg) { } /** - * Apply cache_control from Msg metadata to the converted DashScopeMessage. + * Apply cache_control from Msg metadata to the converted DashScopeMessage at content block + * level. When the metadata flag is set, this converts the message content to array format + * (if needed) and sets cache_control on the last content block. * * @param msg the source message with metadata * @param result the converted DashScope message @@ -260,7 +262,7 @@ private void applyCacheControlFromMetadata(Msg msg, DashScopeMessage result) { } Object cacheFlag = msg.getMetadata().get(MessageMetadataKeys.CACHE_CONTROL); if (Boolean.TRUE.equals(cacheFlag)) { - result.setCacheControl(DashScopeChatFormatter.getEphemeralCacheControl()); + DashScopeChatFormatter.applyCacheControlToContentBlock(result); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMultiAgentFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMultiAgentFormatter.java index 2df49033e..c4a0b3f9a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMultiAgentFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/DashScopeMultiAgentFormatter.java @@ -33,7 +33,6 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.Map; /** * DashScope formatter for multi-agent conversations. @@ -365,11 +364,14 @@ private static class MessageGroup { } /** - * Apply cache control to DashScope messages. + * Apply cache control to DashScope messages at the content block level. * - *

Adds cache_control: {"type": "ephemeral"} to all system messages and the last - * message in the list. Messages that already have cache_control set (e.g., via manual metadata - * marking) will not be overwritten. + *

Per the DashScope API specification, {@code cache_control} must be placed inside content + * blocks (within the {@code content} array), not at the message level. This method delegates + * to {@link DashScopeChatFormatter#applyCacheControlToContentBlock(DashScopeMessage)} for each + * target message. + * + *

Target messages: all system messages and the last message in the list. * * @param messages the list of formatted DashScope messages */ @@ -377,15 +379,12 @@ public void applyCacheControl(List messages) { if (messages == null || messages.isEmpty()) { return; } - Map ephemeral = DashScopeChatFormatter.getEphemeralCacheControl(); for (DashScopeMessage msg : messages) { - if ("system".equals(msg.getRole()) && msg.getCacheControl() == null) { - msg.setCacheControl(ephemeral); + if ("system".equals(msg.getRole())) { + DashScopeChatFormatter.applyCacheControlToContentBlock(msg); } } DashScopeMessage lastMsg = messages.get(messages.size() - 1); - if (lastMsg.getCacheControl() == null) { - lastMsg.setCacheControl(ephemeral); - } + DashScopeChatFormatter.applyCacheControlToContentBlock(lastMsg); } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeContentPart.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeContentPart.java index d2c7915a9..42fe4f2a0 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeContentPart.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeContentPart.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.Map; /** * DashScope content part DTO for multimodal messages. @@ -79,6 +80,10 @@ public class DashScopeContentPart { @JsonProperty("total_pixels") private Integer totalPixels; + /** Cache control configuration for prompt caching (placed at content block level per DashScope API spec). */ + @JsonProperty("cache_control") + private Map cacheControl; + public DashScopeContentPart() {} public String getText() { @@ -153,6 +158,14 @@ public void setTotalPixels(Integer totalPixels) { this.totalPixels = totalPixels; } + public Map getCacheControl() { + return cacheControl; + } + + public void setCacheControl(Map cacheControl) { + this.cacheControl = cacheControl; + } + /** * Get video as URL string. * @@ -290,6 +303,11 @@ public Builder totalPixels(Integer totalPixels) { return this; } + public Builder cacheControl(Map cacheControl) { + part.setCacheControl(cacheControl); + return this; + } + public DashScopeContentPart build() { return part; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIBaseFormatter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIBaseFormatter.java index bf57dd42b..af1a64cf4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIBaseFormatter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIBaseFormatter.java @@ -16,6 +16,7 @@ package io.agentscope.core.formatter.openai; import io.agentscope.core.formatter.AbstractBaseFormatter; +import io.agentscope.core.formatter.openai.dto.OpenAIContentPart; import io.agentscope.core.formatter.openai.dto.OpenAIMessage; import io.agentscope.core.formatter.openai.dto.OpenAIRequest; import io.agentscope.core.formatter.openai.dto.OpenAIResponse; @@ -24,6 +25,7 @@ import io.agentscope.core.model.ToolChoice; import io.agentscope.core.model.ToolSchema; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -170,11 +172,15 @@ public OpenAIRequest buildRequest( } /** - * Apply cache control to OpenAI messages. + * Apply cache control to OpenAI messages at the content block level. * - *

Adds cache_control: {"type": "ephemeral"} to all system messages and the last - * message in the list. Messages that already have cache_control set (e.g., via manual metadata - * marking) will not be overwritten. + *

Per the DashScope API specification (which also applies to the OpenAI-compatible protocol), + * {@code cache_control} must be placed inside content blocks (within the {@code content} array), + * not at the message level. This method converts string content to array format when needed and + * sets {@code cache_control} on the last content block of each target message. + * + *

Target messages: all system messages and the last message in the list. Messages whose last + * content block already has {@code cache_control} set will not be overwritten. * * @param messages the list of formatted OpenAI messages */ @@ -183,14 +189,48 @@ public void applyCacheControl(List messages) { return; } for (OpenAIMessage msg : messages) { - if ("system".equals(msg.getRole()) && msg.getCacheControl() == null) { - msg.setCacheControl(EPHEMERAL_CACHE_CONTROL); + if ("system".equals(msg.getRole())) { + applyCacheControlToContentBlock(msg); } } OpenAIMessage lastMsg = messages.get(messages.size() - 1); - if (lastMsg.getCacheControl() == null) { - lastMsg.setCacheControl(EPHEMERAL_CACHE_CONTROL); + applyCacheControlToContentBlock(lastMsg); + } + + /** + * Apply ephemeral cache_control to the last content block of the given message. + * If content is a plain string, it is first converted to array format. + * Skips if the last content block already has cache_control set. + */ + static void applyCacheControlToContentBlock(OpenAIMessage msg) { + List parts = ensureContentArray(msg); + if (parts.isEmpty()) { + return; + } + OpenAIContentPart lastPart = parts.get(parts.size() - 1); + if (lastPart.getCacheControl() == null) { + lastPart.setCacheControl(EPHEMERAL_CACHE_CONTROL); + } + } + + /** + * Ensure the message content is in array format ({@code List}). + * If content is a plain string, converts it to {@code [{"type":"text","text":"..."}]}. + * + * @return the content part list (never null, may be empty) + */ + @SuppressWarnings("unchecked") + static List ensureContentArray(OpenAIMessage msg) { + Object content = msg.getContent(); + if (content instanceof List) { + return (List) content; + } + List parts = new ArrayList<>(); + if (content instanceof String text) { + parts.add(OpenAIContentPart.text(text)); } + msg.setContent(parts); + return parts; } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java index 47f8584fd..86acd09dd 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/OpenAIMessageConverter.java @@ -469,7 +469,9 @@ private String detectAudioFormat(String mediaType) { } /** - * Apply cache_control from Msg metadata to the converted OpenAIMessage. + * Apply cache_control from Msg metadata to the converted OpenAIMessage at content block level. + * When the metadata flag is set, this converts the message content to array format (if needed) + * and sets cache_control on the last content block. * * @param msg the source message with metadata * @param result the converted OpenAI message @@ -480,7 +482,7 @@ private void applyCacheControlFromMetadata(Msg msg, OpenAIMessage result) { } Object cacheFlag = msg.getMetadata().get(MessageMetadataKeys.CACHE_CONTROL); if (Boolean.TRUE.equals(cacheFlag)) { - result.setCacheControl(OpenAIBaseFormatter.getEphemeralCacheControl()); + OpenAIBaseFormatter.applyCacheControlToContentBlock(result); } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIContentPart.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIContentPart.java index 8f898d4d5..ef0dcd719 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIContentPart.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/openai/dto/OpenAIContentPart.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; /** * OpenAI content part DTO for multimodal messages. @@ -65,6 +66,10 @@ public class OpenAIContentPart { @JsonProperty("video_url") private OpenAIVideoUrl videoUrl; + /** Cache control configuration for prompt caching (placed at content block level per API spec). */ + @JsonProperty("cache_control") + private Map cacheControl; + public OpenAIContentPart() {} public String getType() { @@ -107,6 +112,14 @@ public void setVideoUrl(OpenAIVideoUrl videoUrl) { this.videoUrl = videoUrl; } + public Map getCacheControl() { + return cacheControl; + } + + public void setCacheControl(Map cacheControl) { + this.cacheControl = cacheControl; + } + /** * Create a text content part. * @@ -206,6 +219,11 @@ public Builder videoUrl(OpenAIVideoUrl videoUrl) { return this; } + public Builder cacheControl(Map cacheControl) { + part.setCacheControl(cacheControl); + return this; + } + public OpenAIContentPart build() { OpenAIContentPart result = part; part = new OpenAIContentPart(); diff --git a/agentscope-core/src/test/java/io/agentscope/core/e2e/CacheControlE2ETest.java b/agentscope-core/src/test/java/io/agentscope/core/e2e/CacheControlE2ETest.java new file mode 100644 index 000000000..a798b4abc --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/e2e/CacheControlE2ETest.java @@ -0,0 +1,250 @@ +/* + * Copyright 2024-2026 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 + * + * http://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 io.agentscope.core.e2e; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.TestUtils; +import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.core.formatter.openai.OpenAIChatFormatter; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.OpenAIChatModel; +import io.agentscope.core.tool.Toolkit; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * E2E test for DashScope context caching (cache_control at content block level). + * + *

Verifies that the DashScope API accepts requests with cache_control placed at the content + * block level, which is the correct placement per API documentation. + * + *

Two identical requests are made via a ReActAgent with cacheControl enabled. The first creates + * the cache, and the second should hit the cache. Both requests must succeed without API errors. + * + *

Requirements: DASHSCOPE_API_KEY environment variable must be set. Set + * ENABLE_E2E_TESTS=true to run this test. + */ +@Tag("e2e") +@ExtendWith(E2ETestCondition.class) +@DisplayName("Cache Control E2E Test") +class CacheControlE2ETest { + + private static final Duration TEST_TIMEOUT = Duration.ofSeconds(120); + + /** + * A long system prompt to ensure it meets the minimum token length for caching. DashScope + * requires at least 1024 tokens for cache creation. + */ + private static final String LONG_SYSTEM_PROMPT = + """ + You are a highly knowledgeable assistant specializing in computer science and software \ + engineering. You have deep expertise in the following areas: + + 1. Programming Languages: You are proficient in Java, Python, JavaScript, TypeScript, \ + C++, Rust, Go, and many other languages. You can explain language features, design \ + patterns, and best practices for each. + + 2. Data Structures and Algorithms: You have comprehensive knowledge of arrays, linked \ + lists, trees, graphs, hash tables, heaps, and their various implementations. You can \ + analyze time and space complexity using Big-O notation. + + 3. System Design: You can design scalable, distributed systems including load balancers, \ + caches, message queues, databases, and microservices architectures. You understand CAP \ + theorem, consistency models, and distributed consensus protocols. + + 4. Databases: You are familiar with relational databases (MySQL, PostgreSQL), NoSQL \ + databases (MongoDB, Redis, Cassandra), and NewSQL databases. You understand indexing, \ + query optimization, sharding, and replication strategies. + + 5. Cloud Computing: You have expertise in AWS, Azure, and GCP services. You understand \ + containerization with Docker and Kubernetes, serverless computing, and infrastructure \ + as code with Terraform and CloudFormation. + + 6. Machine Learning and AI: You understand supervised learning, unsupervised learning, \ + reinforcement learning, neural networks, transformers, and large language models. You \ + can explain attention mechanisms, backpropagation, and optimization techniques. + + 7. Security: You are knowledgeable about encryption, authentication, authorization, \ + OAuth, JWT, XSS, CSRF, SQL injection, and other security topics. You understand \ + secure coding practices and threat modeling. + + 8. DevOps and CI/CD: You understand continuous integration, continuous deployment, \ + GitOps, monitoring, logging, and observability. You can set up pipelines using \ + Jenkins, GitHub Actions, GitLab CI, and ArgoCD. + + 9. Software Engineering Practices: You advocate for clean code, SOLID principles, \ + test-driven development, code reviews, and documentation. You understand agile \ + methodologies including Scrum and Kanban. + + 10. Networking: You understand TCP/IP, HTTP/HTTPS, WebSockets, gRPC, DNS, CDN, \ + and network security. You can explain how data flows through the internet stack. + + When answering questions, be concise but thorough. Provide code examples when \ + appropriate. Always consider edge cases and potential pitfalls. If you are unsure \ + about something, say so rather than guessing. Cite relevant documentation or \ + resources when possible. + + Remember to format your responses clearly with proper headings, bullet points, \ + and code blocks. Use markdown formatting for better readability. + + Please respond in a professional and helpful manner. Your goal is to help the \ + user understand complex technical concepts and solve real-world engineering problems. + """; + + @Test + @DisplayName("DashScope should accept cache_control at content block level") + void testDashScopeCacheControlAccepted() { + assumeTrue( + ProviderFactory.hasDashScopeKey(), + "DASHSCOPE_API_KEY not set, skipping cache control E2E test"); + + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + apiKey = System.getProperty("DASHSCOPE_API_KEY"); + } + + DashScopeChatModel model = + DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-plus").stream(false) + .formatter(new DashScopeChatFormatter()) + .defaultOptions(GenerateOptions.builder().cacheControl(true).build()) + .build(); + + ReActAgent agent = + ReActAgent.builder() + .name("CacheControlTestAgent") + .model(model) + .toolkit(new Toolkit()) + .sysPrompt(LONG_SYSTEM_PROMPT) + .memory(new InMemoryMemory()) + .build(); + + // First call: creates the cache + System.out.println("=== Cache Control E2E: First call (cache creation) ==="); + Msg input1 = TestUtils.createUserMessage("User", "What is a binary search tree?"); + Msg response1 = agent.call(input1).block(TEST_TIMEOUT); + + assertNotNull(response1, "First response should not be null"); + String text1 = TestUtils.extractTextContent(response1); + assertNotNull(text1, "First response should have text content"); + assertTrue(!text1.isEmpty(), "First response text should not be empty"); + System.out.println( + "First call succeeded. Response: " + + text1.substring(0, Math.min(100, text1.length())) + + "..."); + + // Reset memory for a clean second call with the same system prompt + agent.getMemory().clear(); + + // Second call: should hit the cache + System.out.println("=== Cache Control E2E: Second call (cache hit expected) ==="); + Msg input2 = TestUtils.createUserMessage("User", "What is a binary search tree?"); + Msg response2 = agent.call(input2).block(TEST_TIMEOUT); + + assertNotNull(response2, "Second response should not be null"); + String text2 = TestUtils.extractTextContent(response2); + assertNotNull(text2, "Second response should have text content"); + assertTrue(!text2.isEmpty(), "Second response text should not be empty"); + System.out.println( + "Second call succeeded. Response: " + + text2.substring(0, Math.min(100, text2.length())) + + "..."); + + System.out.println( + "Cache control E2E test passed - DashScope native API accepted cache_control at" + + " content block level"); + } + + @Test + @DisplayName( + "DashScope OpenAI-compatible endpoint should accept cache_control at content block" + + " level") + void testOpenAICompatibleCacheControlAccepted() { + assumeTrue( + ProviderFactory.hasDashScopeKey(), + "DASHSCOPE_API_KEY not set, skipping OpenAI-compatible cache control E2E test"); + + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + apiKey = System.getProperty("DASHSCOPE_API_KEY"); + } + + OpenAIChatModel model = + OpenAIChatModel.builder() + .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") + .apiKey(apiKey) + .modelName("qwen-plus") + .stream(false) + .formatter(new OpenAIChatFormatter()) + .generateOptions(GenerateOptions.builder().cacheControl(true).build()) + .build(); + + ReActAgent agent = + ReActAgent.builder() + .name("OpenAICacheControlTestAgent") + .model(model) + .toolkit(new Toolkit()) + .sysPrompt(LONG_SYSTEM_PROMPT) + .memory(new InMemoryMemory()) + .build(); + + // First call: creates the cache + System.out.println( + "=== OpenAI-Compatible Cache Control E2E: First call (cache creation) ==="); + Msg input1 = TestUtils.createUserMessage("User", "What is a binary search tree?"); + Msg response1 = agent.call(input1).block(TEST_TIMEOUT); + + assertNotNull(response1, "First response should not be null"); + String text1 = TestUtils.extractTextContent(response1); + assertNotNull(text1, "First response should have text content"); + assertTrue(!text1.isEmpty(), "First response text should not be empty"); + System.out.println( + "First call succeeded. Response: " + + text1.substring(0, Math.min(100, text1.length())) + + "..."); + + // Reset memory for a clean second call with the same system prompt + agent.getMemory().clear(); + + // Second call: should hit the cache + System.out.println( + "=== OpenAI-Compatible Cache Control E2E: Second call (cache hit expected) ==="); + Msg input2 = TestUtils.createUserMessage("User", "What is a binary search tree?"); + Msg response2 = agent.call(input2).block(TEST_TIMEOUT); + + assertNotNull(response2, "Second response should not be null"); + String text2 = TestUtils.extractTextContent(response2); + assertNotNull(text2, "Second response should have text content"); + assertTrue(!text2.isEmpty(), "Second response text should not be empty"); + System.out.println( + "Second call succeeded. Response: " + + text2.substring(0, Math.min(100, text2.length())) + + "..."); + + System.out.println( + "Cache control E2E test passed - OpenAI-compatible endpoint accepted" + + " cache_control at content block level"); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeCacheControlTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeCacheControlTest.java index ab869da37..85f1166d3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeCacheControlTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/dashscope/DashScopeCacheControlTest.java @@ -16,12 +16,17 @@ package io.agentscope.core.formatter.dashscope; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.formatter.dashscope.dto.DashScopeContentPart; import io.agentscope.core.formatter.dashscope.dto.DashScopeMessage; import io.agentscope.core.message.MessageMetadataKeys; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; +import io.agentscope.core.util.JsonCodec; +import io.agentscope.core.util.JsonUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -33,6 +38,7 @@ /** * Tests for cache_control support in DashScope formatter. + * Validates that cache_control is placed at content block level per DashScope API spec. */ class DashScopeCacheControlTest { @@ -45,12 +51,31 @@ void setUp() { formatter = new DashScopeChatFormatter(); } + /** Helper: get the last content part's cacheControl from a message. */ + private Map getLastPartCacheControl(DashScopeMessage msg) { + List parts = msg.getContentAsList(); + assertNotNull(parts, "Content should be array format after applyCacheControl"); + assertTrue(!parts.isEmpty(), "Content parts should not be empty"); + return parts.get(parts.size() - 1).getCacheControl(); + } + + /** Helper: assert no content block in the message has cache_control set. */ + private void assertNoCacheControlOnParts(DashScopeMessage msg) { + List parts = msg.getContentAsList(); + if (parts == null) { + return; + } + for (DashScopeContentPart part : parts) { + assertNull(part.getCacheControl(), "No content block should have cache_control"); + } + } + @Nested - @DisplayName("applyCacheControl - automatic strategy") + @DisplayName("applyCacheControl - content block level") class ApplyCacheControlTest { @Test - @DisplayName("should add cache_control to system and last message") + @DisplayName("should add cache_control to last content block of system and last message") void systemAndLastMessage() { List messages = new ArrayList<>(); messages.add( @@ -61,10 +86,21 @@ void systemAndLastMessage() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - assertNull(messages.get(1).getCacheControl()); - assertNull(messages.get(2).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(3).getCacheControl()); + // system message: content converted to array, last part has cache_control + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertNull( + messages.get(0).getCacheControl(), + "Message-level cache_control should be null"); + + // middle messages: no cache_control + assertNoCacheControlOnParts(messages.get(1)); + assertNoCacheControlOnParts(messages.get(2)); + + // last message: content converted to array, last part has cache_control + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(3))); + assertNull( + messages.get(3).getCacheControl(), + "Message-level cache_control should be null"); } @Test @@ -76,8 +112,8 @@ void noSystemMessage() { formatter.applyCacheControl(messages); - assertNull(messages.get(0).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); + assertNoCacheControlOnParts(messages.get(0)); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); } @Test @@ -85,14 +121,12 @@ void noSystemMessage() { void emptyList() { List messages = new ArrayList<>(); formatter.applyCacheControl(messages); - // No exception thrown } @Test @DisplayName("should handle null list without error") void nullList() { formatter.applyCacheControl(null); - // No exception thrown } @Test @@ -104,51 +138,51 @@ void singleSystemMessage() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); } @Test - @DisplayName("should not overwrite manually marked cache_control") + @DisplayName("should not overwrite content block with existing cache_control") void manuallyMarkedNotOverridden() { Map customCacheControl = Map.of("type", "custom"); - List messages = new ArrayList<>(); - messages.add( - DashScopeMessage.builder() - .role("system") - .content("System") + DashScopeContentPart part = + DashScopeContentPart.builder() + .text("System") .cacheControl(customCacheControl) - .build()); + .build(); + List messages = new ArrayList<>(); + messages.add(DashScopeMessage.builder().role("system").content(List.of(part)).build()); messages.add(DashScopeMessage.builder().role("user").content("User").build()); formatter.applyCacheControl(messages); - // System message keeps its custom cache_control - assertEquals(customCacheControl, messages.get(0).getCacheControl()); - // Last message gets ephemeral - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); + // System message keeps its custom cache_control on the content block + assertEquals(customCacheControl, getLastPartCacheControl(messages.get(0))); + // Last message gets ephemeral on content block + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); } @Test - @DisplayName("should not overwrite last message with existing cache_control") + @DisplayName("should not overwrite last message content block with existing cache_control") void lastMessageManuallyMarkedNotOverridden() { Map customCacheControl = Map.of("type", "custom"); + DashScopeContentPart part = + DashScopeContentPart.builder() + .text("User") + .cacheControl(customCacheControl) + .build(); List messages = new ArrayList<>(); messages.add(DashScopeMessage.builder().role("system").content("System").build()); - messages.add( - DashScopeMessage.builder() - .role("user") - .content("User") - .cacheControl(customCacheControl) - .build()); + messages.add(DashScopeMessage.builder().role("user").content(List.of(part)).build()); formatter.applyCacheControl(messages); // System message gets ephemeral - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); // Last message keeps its custom cache_control - assertEquals(customCacheControl, messages.get(1).getCacheControl()); + assertEquals(customCacheControl, getLastPartCacheControl(messages.get(1))); } @Test @@ -161,9 +195,79 @@ void multipleSystemMessages() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(2).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(2))); + } + + @Test + @DisplayName("should convert string content to array format") + void stringContentConvertedToArray() { + List messages = new ArrayList<>(); + messages.add(DashScopeMessage.builder().role("system").content("Hello world").build()); + + formatter.applyCacheControl(messages); + + // Content should now be array format + assertTrue(messages.get(0).isMultimodal(), "Content should be array format"); + List parts = messages.get(0).getContentAsList(); + assertNotNull(parts); + assertEquals(1, parts.size()); + assertEquals("Hello world", parts.get(0).getText()); + assertEquals(EPHEMERAL, parts.get(0).getCacheControl()); + } + + @Test + @DisplayName("should set cache_control on last part when content is already array") + void arrayContentLastPartMarked() { + List contentParts = + List.of( + DashScopeContentPart.text("First part"), + DashScopeContentPart.text("Second part"), + DashScopeContentPart.text("Third part")); + List messages = new ArrayList<>(); + messages.add( + DashScopeMessage.builder() + .role("system") + .content(new ArrayList<>(contentParts)) + .build()); + + formatter.applyCacheControl(messages); + + List parts = messages.get(0).getContentAsList(); + assertNull(parts.get(0).getCacheControl()); + assertNull(parts.get(1).getCacheControl()); + assertEquals(EPHEMERAL, parts.get(2).getCacheControl()); + } + } + + @Nested + @DisplayName("JSON serialization verification") + class JsonSerializationTest { + + @Test + @DisplayName("cache_control should appear inside content block, not at message level") + void cacheControlInContentBlock() throws Exception { + List messages = new ArrayList<>(); + messages.add( + DashScopeMessage.builder().role("system").content("You are helpful.").build()); + messages.add(DashScopeMessage.builder().role("user").content("Hello").build()); + + formatter.applyCacheControl(messages); + + JsonCodec jsonCodec = JsonUtils.getJsonCodec(); + String json = jsonCodec.toJson(messages); + + // cache_control should be within content blocks + assertTrue(json.contains("\"cache_control\""), "JSON should contain cache_control"); + assertTrue(json.contains("\"ephemeral\""), "JSON should contain ephemeral"); + + // Verify message-level cache_control is NOT present + for (DashScopeMessage msg : messages) { + assertNull( + msg.getCacheControl(), + "Message-level cache_control should be null for role: " + msg.getRole()); + } } } @@ -172,7 +276,7 @@ void multipleSystemMessages() { class MetadataMarkingTest { @Test - @DisplayName("should set cache_control from Msg metadata") + @DisplayName("should set cache_control on content block from Msg metadata") void metadataMarking() { Map metadata = new HashMap<>(); metadata.put(MessageMetadataKeys.CACHE_CONTROL, true); @@ -186,7 +290,10 @@ void metadataMarking() { List result = formatter.format(List.of(msg)); assertEquals(1, result.size()); - assertEquals(EPHEMERAL, result.get(0).getCacheControl()); + // cache_control should be on content block, not message + assertNull( + result.get(0).getCacheControl(), "Message-level cache_control should be null"); + assertEquals(EPHEMERAL, getLastPartCacheControl(result.get(0))); } @Test @@ -219,7 +326,7 @@ void metadataFalse() { } @Test - @DisplayName("should set cache_control on system message via metadata") + @DisplayName("should set cache_control on system message content block via metadata") void systemMessageMetadata() { Map metadata = new HashMap<>(); metadata.put(MessageMetadataKeys.CACHE_CONTROL, true); @@ -234,7 +341,10 @@ void systemMessageMetadata() { List result = formatter.format(List.of(systemMsg, userMsg)); assertEquals(2, result.size()); - assertEquals(EPHEMERAL, result.get(0).getCacheControl()); + // System message: cache_control on content block + assertNull(result.get(0).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(result.get(0))); + // User message: no cache_control assertNull(result.get(1).getCacheControl()); } } @@ -244,7 +354,7 @@ void systemMessageMetadata() { class MultiAgentFormatterTest { @Test - @DisplayName("should add cache_control to system and last message") + @DisplayName("should add cache_control to content blocks of system and last message") void applyCacheControl() { DashScopeMultiAgentFormatter multiFormatter = new DashScopeMultiAgentFormatter(); @@ -255,8 +365,11 @@ void applyCacheControl() { multiFormatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); + // message-level should be null + assertNull(messages.get(0).getCacheControl()); + assertNull(messages.get(1).getCacheControl()); } } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAICacheControlTest.java b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAICacheControlTest.java index 25f8e81cd..9ce89af30 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAICacheControlTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/formatter/openai/OpenAICacheControlTest.java @@ -16,8 +16,11 @@ package io.agentscope.core.formatter.openai; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.formatter.openai.dto.OpenAIContentPart; import io.agentscope.core.formatter.openai.dto.OpenAIMessage; import io.agentscope.core.message.MessageMetadataKeys; import io.agentscope.core.message.Msg; @@ -33,6 +36,7 @@ /** * Tests for cache_control support in OpenAI formatter. + * Validates that cache_control is placed at content block level. */ class OpenAICacheControlTest { @@ -45,12 +49,31 @@ void setUp() { formatter = new OpenAIChatFormatter(); } + /** Helper: get the last content part's cacheControl from a message. */ + private Map getLastPartCacheControl(OpenAIMessage msg) { + List parts = msg.getContentAsList(); + assertNotNull(parts, "Content should be array format after applyCacheControl"); + assertTrue(!parts.isEmpty(), "Content parts should not be empty"); + return parts.get(parts.size() - 1).getCacheControl(); + } + + /** Helper: assert no content block in the message has cache_control set. */ + private void assertNoCacheControlOnParts(OpenAIMessage msg) { + List parts = msg.getContentAsList(); + if (parts == null) { + return; + } + for (OpenAIContentPart part : parts) { + assertNull(part.getCacheControl(), "No content block should have cache_control"); + } + } + @Nested - @DisplayName("applyCacheControl - automatic strategy") + @DisplayName("applyCacheControl - content block level") class ApplyCacheControlTest { @Test - @DisplayName("should add cache_control to system and last message") + @DisplayName("should add cache_control to last content block of system and last message") void systemAndLastMessage() { List messages = new ArrayList<>(); messages.add( @@ -61,10 +84,14 @@ void systemAndLastMessage() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - assertNull(messages.get(1).getCacheControl()); - assertNull(messages.get(2).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(3).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertNull(messages.get(0).getCacheControl()); + + assertNoCacheControlOnParts(messages.get(1)); + assertNoCacheControlOnParts(messages.get(2)); + + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(3))); + assertNull(messages.get(3).getCacheControl()); } @Test @@ -76,8 +103,8 @@ void noSystemMessage() { formatter.applyCacheControl(messages); - assertNull(messages.get(0).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); + assertNoCacheControlOnParts(messages.get(0)); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); } @Test @@ -85,14 +112,12 @@ void noSystemMessage() { void emptyList() { List messages = new ArrayList<>(); formatter.applyCacheControl(messages); - // No exception thrown } @Test @DisplayName("should handle null list without error") void nullList() { formatter.applyCacheControl(null); - // No exception thrown } @Test @@ -104,51 +129,49 @@ void singleSystemMessage() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); } @Test - @DisplayName("should not overwrite manually marked cache_control") + @DisplayName("should not overwrite content block with existing cache_control") void manuallyMarkedNotOverridden() { Map customCacheControl = Map.of("type", "custom"); - List messages = new ArrayList<>(); - messages.add( - OpenAIMessage.builder() - .role("system") - .content("System") + OpenAIContentPart part = + OpenAIContentPart.builder() + .type("text") + .text("System") .cacheControl(customCacheControl) - .build()); + .build(); + List messages = new ArrayList<>(); + messages.add(OpenAIMessage.builder().role("system").content(List.of(part)).build()); messages.add(OpenAIMessage.builder().role("user").content("User").build()); formatter.applyCacheControl(messages); - // System message keeps its custom cache_control - assertEquals(customCacheControl, messages.get(0).getCacheControl()); - // Last message gets ephemeral - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); + assertEquals(customCacheControl, getLastPartCacheControl(messages.get(0))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); } @Test - @DisplayName("should not overwrite last message with existing cache_control") + @DisplayName("should not overwrite last message content block with existing cache_control") void lastMessageManuallyMarkedNotOverridden() { Map customCacheControl = Map.of("type", "custom"); + OpenAIContentPart part = + OpenAIContentPart.builder() + .type("text") + .text("User") + .cacheControl(customCacheControl) + .build(); List messages = new ArrayList<>(); messages.add(OpenAIMessage.builder().role("system").content("System").build()); - messages.add( - OpenAIMessage.builder() - .role("user") - .content("User") - .cacheControl(customCacheControl) - .build()); + messages.add(OpenAIMessage.builder().role("user").content(List.of(part)).build()); formatter.applyCacheControl(messages); - // System message gets ephemeral - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - // Last message keeps its custom cache_control - assertEquals(customCacheControl, messages.get(1).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertEquals(customCacheControl, getLastPartCacheControl(messages.get(1))); } @Test @@ -161,9 +184,26 @@ void multipleSystemMessages() { formatter.applyCacheControl(messages); - assertEquals(EPHEMERAL, messages.get(0).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(1).getCacheControl()); - assertEquals(EPHEMERAL, messages.get(2).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(0))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(1))); + assertEquals(EPHEMERAL, getLastPartCacheControl(messages.get(2))); + } + + @Test + @DisplayName("should convert string content to array format") + void stringContentConvertedToArray() { + List messages = new ArrayList<>(); + messages.add(OpenAIMessage.builder().role("system").content("Hello world").build()); + + formatter.applyCacheControl(messages); + + assertTrue(messages.get(0).isMultimodal(), "Content should be array format"); + List parts = messages.get(0).getContentAsList(); + assertNotNull(parts); + assertEquals(1, parts.size()); + assertEquals("text", parts.get(0).getType()); + assertEquals("Hello world", parts.get(0).getText()); + assertEquals(EPHEMERAL, parts.get(0).getCacheControl()); } } @@ -172,7 +212,7 @@ void multipleSystemMessages() { class MetadataMarkingTest { @Test - @DisplayName("should set cache_control from Msg metadata") + @DisplayName("should set cache_control on content block from Msg metadata") void metadataMarking() { Map metadata = new HashMap<>(); metadata.put(MessageMetadataKeys.CACHE_CONTROL, true); @@ -186,7 +226,9 @@ void metadataMarking() { List result = formatter.format(List.of(msg)); assertEquals(1, result.size()); - assertEquals(EPHEMERAL, result.get(0).getCacheControl()); + assertNull( + result.get(0).getCacheControl(), "Message-level cache_control should be null"); + assertEquals(EPHEMERAL, getLastPartCacheControl(result.get(0))); } @Test @@ -219,7 +261,7 @@ void metadataFalse() { } @Test - @DisplayName("should set cache_control on system message via metadata") + @DisplayName("should set cache_control on system message content block via metadata") void systemMessageMetadata() { Map metadata = new HashMap<>(); metadata.put(MessageMetadataKeys.CACHE_CONTROL, true); @@ -234,7 +276,8 @@ void systemMessageMetadata() { List result = formatter.format(List.of(systemMsg, userMsg)); assertEquals(2, result.size()); - assertEquals(EPHEMERAL, result.get(0).getCacheControl()); + assertNull(result.get(0).getCacheControl()); + assertEquals(EPHEMERAL, getLastPartCacheControl(result.get(0))); assertNull(result.get(1).getCacheControl()); } }