From 6a35378d2df2f74a13a04092c2c413a5feb6242a Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 10:05:38 +0800 Subject: [PATCH 01/11] fix OmniRealtimeConversation WebSocket lifecycle and error handling - checkStatus() now also checks isOpen to detect unconnected state - onFailure() sets isClosed=true and isOpen=false for consistency - onClosed() sets isClosed=true to match onClosing() - close() is now idempotent via compareAndSet and null-safe - sendMessage() throws on send failure instead of silently ignoring - connect() skips isOpen check to allow initial connection - onClosing() delegates to close() to avoid duplicated logic --- .../audio/omni/OmniRealtimeConversation.java | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java index 2b2dcfd..1a84cdc 100644 --- a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java +++ b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java @@ -57,11 +57,16 @@ public void checkStatus() { if (this.isClosed.get()) { throw new RuntimeException("conversation is already closed!"); } + if (!this.isOpen.get()) { + throw new RuntimeException("conversation is not connected!"); + } } /** Connect to server, create session and return default session configuration */ public void connect() throws NoApiKeyException, InterruptedException { - checkStatus(); + if (isClosed.get()) { + throw new RuntimeException("conversation is already closed!"); + } Request request = buildConnectionRequest( ApiKey.getApiKey(parameters.getApikey()), @@ -250,9 +255,7 @@ public void cancelResponse() { /** close the connection to server */ public void close() { - checkStatus(); - websocktetClient.close(1000, "bye"); - isClosed.set(true); + close(1000, "bye"); } /** @@ -262,9 +265,13 @@ public void close() { * @param reason websocket close reason */ public void close(int code, String reason) { - checkStatus(); - websocktetClient.close(code, reason); - isClosed.set(true); + if (!isClosed.compareAndSet(false, true)) { + return; + } + if (websocktetClient != null) { + websocktetClient.close(code, reason); + } + isOpen.set(false); } /** @@ -340,6 +347,9 @@ private void sendMessage(String message, boolean enableLog) { log.debug("send message: " + message); } Boolean isOk = websocktetClient.send(message); + if (!isOk) { + throw new RuntimeException("failed to send message"); + } } private void sendMessage(ByteString message) { @@ -410,6 +420,7 @@ public void onMessage(WebSocket webSocket, String text) { @Override public void onClosed(WebSocket webSocket, int code, String reason) { isOpen.set(false); + isClosed.set(true); connectLatch.get().countDown(); log.debug("WebSocket closed: " + code + ", " + reason); callback.onClose(code, reason); @@ -417,14 +428,15 @@ public void onClosed(WebSocket webSocket, int code, String reason) { @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { + isClosed.set(true); + isOpen.set(false); connectLatch.get().countDown(); log.error("WebSocket failed: " + t.getMessage()); } @Override public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { - isClosed.set(true); - websocktetClient.close(code, reason); + close(code, reason); log.debug("WebSocket closing: " + code + ", " + reason); } } From 469a0ba81a29f73ac9252a9eab0341ba504e8045 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 10:19:14 +0800 Subject: [PATCH 02/11] bump version to 2.22.24 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 55d9df8..14f0b2a 100644 --- a/pom.xml +++ b/pom.xml @@ -40,7 +40,7 @@ DashScope Java SDK com.alibaba dashscope-sdk-java - 2.22.23 + 2.22.24 8 From fb0899544475df62d859bd6c3bcc1e9cdfacbcff Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 10:59:02 +0800 Subject: [PATCH 03/11] fix OmniRealtimeConversation: prevent duplicate connect and release endSession latch on disconnect - connect() now rejects re-connection when already open to avoid resource leaks - onClosed() and onFailure() count down disconnectLatch so endSession() does not block until timeout when the connection is lost --- .../audio/omni/OmniRealtimeConversation.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java index 1a84cdc..fc3c857 100644 --- a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java +++ b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java @@ -67,6 +67,9 @@ public void connect() throws NoApiKeyException, InterruptedException { if (isClosed.get()) { throw new RuntimeException("conversation is already closed!"); } + if (isOpen.get()) { + throw new RuntimeException("conversation is already connected!"); + } Request request = buildConnectionRequest( ApiKey.getApiKey(parameters.getApikey()), @@ -422,6 +425,10 @@ public void onClosed(WebSocket webSocket, int code, String reason) { isOpen.set(false); isClosed.set(true); connectLatch.get().countDown(); + CountDownLatch latch = disconnectLatch.get(); + if (latch != null) { + latch.countDown(); + } log.debug("WebSocket closed: " + code + ", " + reason); callback.onClose(code, reason); } @@ -431,6 +438,10 @@ public void onFailure(WebSocket webSocket, Throwable t, Response response) { isClosed.set(true); isOpen.set(false); connectLatch.get().countDown(); + CountDownLatch latch = disconnectLatch.get(); + if (latch != null) { + latch.countDown(); + } log.error("WebSocket failed: " + t.getMessage()); } From 54c42a85aad17adf3bb19c8fcc56c3cb4d82ebaf Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 10:59:12 +0800 Subject: [PATCH 04/11] defensive output type check in DashScopeResult for encrypted responses When the server encrypts output without setting X-DashScope-OutputEncrypted header, the SDK previously assumed output was always a JSON object and threw on string-type values. Now both the encrypted and non-encrypted paths inspect the actual JSON type before casting, preventing IllegalStateException and UnsupportedOperationException. --- .../dashscope/common/DashScopeResult.java | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index ad71ae6..8afdf81 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -94,10 +94,13 @@ protected T fromResponse(Protocol protocol, NetworkResponse r this.setStatusCode(response.getHttpStatusCode()); } if (jsonObject.has(ApiKeywords.OUTPUT)) { - this.output = - jsonObject.get(ApiKeywords.OUTPUT).isJsonNull() - ? null - : jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + if (jsonObject.get(ApiKeywords.OUTPUT).isJsonNull()) { + this.output = null; + } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonObject()) { + this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + } else { + this.output = jsonObject.get(ApiKeywords.OUTPUT); + } } if (jsonObject.has(ApiKeywords.USAGE)) { this.setUsage( @@ -183,17 +186,19 @@ public T fromResponse( this.setStatusCode(response.getHttpStatusCode()); } JsonObject jsonObject = JsonUtils.parse(response.getMessage()); - String encryptedOutput = - jsonObject.get(ApiKeywords.OUTPUT).isJsonNull() - ? null - : jsonObject.get(ApiKeywords.OUTPUT).getAsString(); - if (encryptedOutput != null) { - String plainOutput = - EncryptionUtils.AESDecrypt( - encryptedOutput, - req.getEncryptionConfig().getAESEncryptKey(), - req.getEncryptionConfig().getIv()); - this.output = JsonUtils.parse(plainOutput); + if (jsonObject.has(ApiKeywords.OUTPUT) && !jsonObject.get(ApiKeywords.OUTPUT).isJsonNull()) { + if (jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive() + && jsonObject.get(ApiKeywords.OUTPUT).getAsJsonPrimitive().isString()) { + String encryptedOutput = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); + String plainOutput = + EncryptionUtils.AESDecrypt( + encryptedOutput, + req.getEncryptionConfig().getAESEncryptKey(), + req.getEncryptionConfig().getIv()); + this.output = JsonUtils.parse(plainOutput); + } else { + this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + } } else { this.output = null; } From 95a9e8faa03f53ad7a8ec3448084d88dfbd9009d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 11:20:56 +0800 Subject: [PATCH 05/11] fix encrypted output decryption when server omits X-DashScope-OutputEncrypted header - Add fallback decryption in DashScopeResult.fromResponse() 4-arg path: when server encrypts output but does not set the encryption header, detect output as a JSON string and decrypt using the request's encryption config (AES key + IV) - Add instanceof JsonObject guards in all fromDashScopeResult() methods (Generation, MultiModalConversation, ImageGeneration, ImageSynthesis, Conversation, Application) to prevent ClassCastException when output is not a JSON object, logging the issue instead --- .../aigc/conversation/ConversationResult.java | 14 ++++--- .../aigc/generation/GenerationResult.java | 4 +- .../ImageGenerationResult.java | 4 +- .../imagesynthesis/ImageSynthesisResult.java | 4 +- .../MultiModalConversationResult.java | 4 +- .../dashscope/app/ApplicationResult.java | 4 +- .../dashscope/common/DashScopeResult.java | 37 +++++++++++++++++++ 7 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java b/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java index eaafca4..9846d83 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java @@ -22,11 +22,15 @@ public static ConversationResult fromDashScopeResult(DashScopeResult dashScopeRe ConversationResult result = new ConversationResult(); result.setRequestId(dashScopeResult.getRequestId()); result.setHeaders(dashScopeResult.getHeaders()); - result.setUsage( - JsonUtils.fromJsonObject( - dashScopeResult.getUsage().getAsJsonObject(), GenerationUsage.class)); - result.setOutput( - JsonUtils.fromJsonObject((JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); + if (dashScopeResult.getUsage() != null) { + result.setUsage( + JsonUtils.fromJsonObject( + dashScopeResult.getUsage().getAsJsonObject(), GenerationUsage.class)); + } + if (dashScopeResult.getOutput() instanceof JsonObject) { + result.setOutput( + JsonUtils.fromJsonObject((JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); + } return result; } } diff --git a/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java b/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java index cc270db..ed3b2ea 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java @@ -38,12 +38,12 @@ public static GenerationResult fromDashScopeResult(DashScopeResult dashScopeResu JsonUtils.fromJsonObject( dashScopeResult.getUsage().getAsJsonObject(), GenerationUsage.class)); } - if (dashScopeResult.getOutput() != null) { + if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); } else { - log.error(StringUtils.format("Result no output: %s", dashScopeResult)); + log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/aigc/imagegeneration/ImageGenerationResult.java b/src/main/java/com/alibaba/dashscope/aigc/imagegeneration/ImageGenerationResult.java index 69093b0..7a90e34 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/imagegeneration/ImageGenerationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/imagegeneration/ImageGenerationResult.java @@ -36,12 +36,12 @@ public static ImageGenerationResult fromDashScopeResult(DashScopeResult dashScop JsonUtils.fromJsonObject( dashScopeResult.getUsage().getAsJsonObject(), ImageGenerationUsage.class)); } - if (dashScopeResult.getOutput() != null) { + if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), ImageGenerationOutput.class)); } else { - log.error("Result no output: {}", dashScopeResult); + log.error("Result no output or output is not a JsonObject: {}", dashScopeResult); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java b/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java index 74a8a4f..d1a76ae 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java @@ -41,12 +41,12 @@ public static ImageSynthesisResult fromDashScopeResult(DashScopeResult dashScope JsonUtils.fromJsonObject( dashScopeResult.getUsage().getAsJsonObject(), ImageSynthesisUsage.class)); } - if (dashScopeResult.getOutput() != null) { + if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), ImageSynthesisOutput.class)); } else { - log.error(StringUtils.format("Result no output: %s", dashScopeResult)); + log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/aigc/multimodalconversation/MultiModalConversationResult.java b/src/main/java/com/alibaba/dashscope/aigc/multimodalconversation/MultiModalConversationResult.java index 309ddc0..9492535 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/multimodalconversation/MultiModalConversationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/multimodalconversation/MultiModalConversationResult.java @@ -36,12 +36,12 @@ public static MultiModalConversationResult fromDashScopeResult(DashScopeResult d JsonUtils.fromJsonObject( dashScopeResult.getUsage().getAsJsonObject(), MultiModalConversationUsage.class)); } - if (dashScopeResult.getOutput() != null) { + if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), MultiModalConversationOutput.class)); } else { - log.error("Result no output: {}", dashScopeResult); + log.error("Result no output or output is not a JsonObject: {}", dashScopeResult); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java b/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java index 1d5260d..ca1c52e 100644 --- a/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java +++ b/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java @@ -56,12 +56,12 @@ public static ApplicationResult fromDashScopeResult(DashScopeResult dashScopeRes JsonUtils.fromJsonObject( dashScopeResult.getUsage().getAsJsonObject(), ApplicationUsage.class)); } - if (dashScopeResult.getOutput() != null) { + if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), ApplicationOutput.class)); } else { - log.error(StringUtils.format("Result no output: %s", dashScopeResult)); + log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index 8afdf81..711b51b 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -16,7 +16,9 @@ import java.util.stream.Collectors; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Data @EqualsAndHashCode(callSuper = true) public class DashScopeResult extends Result { @@ -242,6 +244,41 @@ public T fromResponse( } return (T) this; } + + // Fallback: server encrypted output but did not set X-DashScope-OutputEncrypted header + if (protocol == Protocol.HTTP && req.getEncryptionConfig() != null) { + try { + JsonObject jsonObject = JsonUtils.parse(response.getMessage()); + if (jsonObject.has(ApiKeywords.OUTPUT) + && !jsonObject.get(ApiKeywords.OUTPUT).isJsonNull() + && jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive() + && jsonObject.get(ApiKeywords.OUTPUT).getAsJsonPrimitive().isString()) { + String encryptedOutput = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); + String plainOutput = + EncryptionUtils.AESDecrypt( + encryptedOutput, + req.getEncryptionConfig().getAESEncryptKey(), + req.getEncryptionConfig().getIv()); + this.output = JsonUtils.parse(plainOutput); + if (response.getHttpStatusCode() != null) { + this.setStatusCode(response.getHttpStatusCode()); + } + if (jsonObject.has(ApiKeywords.USAGE)) { + this.setUsage( + jsonObject.get(ApiKeywords.USAGE).isJsonNull() + ? null + : jsonObject.get(ApiKeywords.USAGE).getAsJsonObject()); + } + if (jsonObject.has(ApiKeywords.REQUEST_ID)) { + this.setRequestId(jsonObject.get(ApiKeywords.REQUEST_ID).getAsString()); + } + return (T) this; + } + } catch (Exception e) { + log.debug("Fallback decryption failed, proceeding with normal parsing: {}", e.getMessage()); + } + } + return fromResponse(protocol, response, isFlattenResult); } From 8ec22db137e65913b97acbaf724ef5378441d70b Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 11:25:04 +0800 Subject: [PATCH 06/11] style: fix code formatting --- .../dashscope/aigc/conversation/ConversationResult.java | 3 ++- .../alibaba/dashscope/aigc/generation/GenerationResult.java | 4 +++- .../dashscope/aigc/imagesynthesis/ImageSynthesisResult.java | 4 +++- .../java/com/alibaba/dashscope/app/ApplicationResult.java | 4 +++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java b/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java index 9846d83..f517363 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/conversation/ConversationResult.java @@ -29,7 +29,8 @@ public static ConversationResult fromDashScopeResult(DashScopeResult dashScopeRe } if (dashScopeResult.getOutput() instanceof JsonObject) { result.setOutput( - JsonUtils.fromJsonObject((JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); + JsonUtils.fromJsonObject( + (JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java b/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java index ed3b2ea..918b8f0 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/generation/GenerationResult.java @@ -43,7 +43,9 @@ public static GenerationResult fromDashScopeResult(DashScopeResult dashScopeResu JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), GenerationOutput.class)); } else { - log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); + log.error( + StringUtils.format( + "Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java b/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java index d1a76ae..76f8229 100644 --- a/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java +++ b/src/main/java/com/alibaba/dashscope/aigc/imagesynthesis/ImageSynthesisResult.java @@ -46,7 +46,9 @@ public static ImageSynthesisResult fromDashScopeResult(DashScopeResult dashScope JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), ImageSynthesisOutput.class)); } else { - log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); + log.error( + StringUtils.format( + "Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; } diff --git a/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java b/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java index ca1c52e..4d58eea 100644 --- a/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java +++ b/src/main/java/com/alibaba/dashscope/app/ApplicationResult.java @@ -61,7 +61,9 @@ public static ApplicationResult fromDashScopeResult(DashScopeResult dashScopeRes JsonUtils.fromJsonObject( (JsonObject) dashScopeResult.getOutput(), ApplicationOutput.class)); } else { - log.error(StringUtils.format("Result no output or output is not a JsonObject: %s", dashScopeResult)); + log.error( + StringUtils.format( + "Result no output or output is not a JsonObject: %s", dashScopeResult)); } return result; From d0e9fe6fb0ada5371b97cbbe915afa5e51a3ddd0 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 11:38:18 +0800 Subject: [PATCH 07/11] feat: include server close code and reason in checkStatus exception --- .../audio/omni/OmniRealtimeConversation.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java index fc3c857..4951883 100644 --- a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java +++ b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java @@ -39,6 +39,8 @@ public class OmniRealtimeConversation extends WebSocketListener { private long lastFirstAudioDelay = -1; private long lastFirstTextDelay = -1; private AtomicBoolean isClosed = new AtomicBoolean(false); + private volatile int closeCode = -1; + private volatile String closeReason = null; private final AtomicReference disconnectLatch = new AtomicReference<>(null); /** @@ -55,7 +57,11 @@ public OmniRealtimeConversation(OmniRealtimeParam param, OmniRealtimeCallback ca /** Omni APIs */ public void checkStatus() { if (this.isClosed.get()) { - throw new RuntimeException("conversation is already closed!"); + String msg = "conversation is already closed!"; + if (closeCode >= 0) { + msg = msg + " (code=" + closeCode + ", reason=" + closeReason + ")"; + } + throw new RuntimeException(msg); } if (!this.isOpen.get()) { throw new RuntimeException("conversation is not connected!"); @@ -435,6 +441,8 @@ public void onClosed(WebSocket webSocket, int code, String reason) { @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { + this.closeCode = -1; + this.closeReason = "failure: " + t.getMessage(); isClosed.set(true); isOpen.set(false); connectLatch.get().countDown(); @@ -447,6 +455,8 @@ public void onFailure(WebSocket webSocket, Throwable t, Response response) { @Override public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { + this.closeCode = code; + this.closeReason = reason; close(code, reason); log.debug("WebSocket closing: " + code + ", " + reason); } From 2d481a54a5a8d2f0d8190a1750f502d84a0c5824 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 14:05:49 +0800 Subject: [PATCH 08/11] Detect encrypted output when client encryption is disabled When server returns encrypted output but client didn't enable encryption, throw clear ApiException instead of letting encrypted string leak to downstream parsers causing fastjson syntax errors. Detection logic: output is JsonPrimitive, length > 100, matches base64 pattern. Exception message guides users to set enableEncrypt(true). Co-Authored-By: Claude Opus 4.7 --- .../alibaba/dashscope/common/DashScopeResult.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index 711b51b..cec466c 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -100,6 +100,20 @@ protected T fromResponse(Protocol protocol, NetworkResponse r this.output = null; } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonObject()) { this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive()) { + // Server returned encrypted output but client didn't enable encryption + String outputStr = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); + if (outputStr.length() > 100 && outputStr.matches("^[A-Za-z0-9+/=]+$")) { + throw new ApiException( + Status.builder() + .statusCode(400) + .code("EncryptionMismatch") + .message( + "Server returned encrypted output but client encryption is not enabled. " + + "Please set enableEncrypt(true) in your request parameters.") + .build()); + } + this.output = jsonObject.get(ApiKeywords.OUTPUT); } else { this.output = jsonObject.get(ApiKeywords.OUTPUT); } From cc6c174f2df86f44a32860ee50c197f3490958b0 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 14:51:35 +0800 Subject: [PATCH 09/11] Extend encrypted output detection to isFlatten code path The 3-arg fromResponse method with isFlattenResult=true was missing encryption detection, causing fastjson parse errors when server returned encrypted base64 output. Added same defensive check as the non-flatten path to throw clear EncryptionMismatch exception. Co-Authored-By: Claude Opus 4.7 --- .../dashscope/common/DashScopeResult.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index cec466c..f63995f 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -180,7 +180,30 @@ public T fromResponse( } } else { // HTTP JsonObject jsonObject = JsonUtils.parse(response.getMessage()); - this.output = jsonObject; + // Check if output field contains encrypted content + if (jsonObject.has(ApiKeywords.OUTPUT)) { + if (jsonObject.get(ApiKeywords.OUTPUT).isJsonNull()) { + this.output = null; + } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonObject()) { + this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive()) { + // Server returned encrypted output but client didn't enable encryption + String outputStr = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); + if (outputStr.length() > 100 && outputStr.matches("^[A-Za-z0-9+/=]+$")) { + throw new ApiException( + Status.builder() + .statusCode(400) + .code("EncryptionMismatch") + .message( + "Server returned encrypted output but client encryption is not enabled. " + + "Please set enableEncrypt(true) in your request parameters.") + .build()); + } + this.output = jsonObject.get(ApiKeywords.OUTPUT); + } else { + this.output = jsonObject.get(ApiKeywords.OUTPUT); + } + } this.event = response.getEvent(); } return (T) this; From 585d30396d40359609e6ba1a34ffe88ac4d25b43 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 15:26:13 +0800 Subject: [PATCH 10/11] fix: address review findings for DashScopeResult and OmniRealtimeConversation DashScopeResult: - Restore original isFlatten behavior: output = entire JSON object (not just output field) - Remove heuristic encrypted-output detection (length>100 + base64 regex) that caused false positives - Extract parseOutputField() to unify output parsing logic across 3 code paths - Fallback decryption only triggers when req.getEncryptionConfig() != null OmniRealtimeConversation: - Replace volatile closeCode/closeReason with AtomicReference for atomic updates - onFailure now calls callback.onClose(-1, "failure:" + msg) to notify downstream - onClosed also records closeInfo for consistency Tests (23 new, all passing): - TestDashScopeResult: output type-safety (JsonObject/JsonPrimitive/JsonNull/absent), isFlatten behavior, encryption fallback with/without header, no-false-positive when config null - TestOmniRealtimeConversation: close() idempotency, onFailure callback, onClosing checkStatus, connect() state guards, onClosed callback, disconnectLatch release - TestResultTypeSafety: Result subclasses handle JsonPrimitive output gracefully --- .../audio/omni/OmniRealtimeConversation.java | 28 +- .../dashscope/common/DashScopeResult.java | 80 ++--- .../omni/TestOmniRealtimeConversation.java | 229 +++++++++++++ .../dashscope/common/TestDashScopeResult.java | 324 ++++++++++++++++++ .../common/TestResultTypeSafety.java | 56 +++ 5 files changed, 658 insertions(+), 59 deletions(-) create mode 100644 src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java create mode 100644 src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java create mode 100644 src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java diff --git a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java index 4951883..27e7f3f 100644 --- a/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java +++ b/src/main/java/com/alibaba/dashscope/audio/omni/OmniRealtimeConversation.java @@ -39,8 +39,19 @@ public class OmniRealtimeConversation extends WebSocketListener { private long lastFirstAudioDelay = -1; private long lastFirstTextDelay = -1; private AtomicBoolean isClosed = new AtomicBoolean(false); - private volatile int closeCode = -1; - private volatile String closeReason = null; + + /** Immutable holder for WebSocket close code and reason, updated atomically. */ + private static class CloseInfo { + final int code; + final String reason; + + CloseInfo(int code, String reason) { + this.code = code; + this.reason = reason; + } + } + + private final AtomicReference closeInfo = new AtomicReference<>(null); private final AtomicReference disconnectLatch = new AtomicReference<>(null); /** @@ -58,8 +69,9 @@ public OmniRealtimeConversation(OmniRealtimeParam param, OmniRealtimeCallback ca public void checkStatus() { if (this.isClosed.get()) { String msg = "conversation is already closed!"; - if (closeCode >= 0) { - msg = msg + " (code=" + closeCode + ", reason=" + closeReason + ")"; + CloseInfo ci = closeInfo.get(); + if (ci != null && ci.code >= 0) { + msg = msg + " (code=" + ci.code + ", reason=" + ci.reason + ")"; } throw new RuntimeException(msg); } @@ -430,6 +442,7 @@ public void onMessage(WebSocket webSocket, String text) { public void onClosed(WebSocket webSocket, int code, String reason) { isOpen.set(false); isClosed.set(true); + closeInfo.set(new CloseInfo(code, reason)); connectLatch.get().countDown(); CountDownLatch latch = disconnectLatch.get(); if (latch != null) { @@ -441,8 +454,7 @@ public void onClosed(WebSocket webSocket, int code, String reason) { @Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { - this.closeCode = -1; - this.closeReason = "failure: " + t.getMessage(); + closeInfo.set(new CloseInfo(-1, "failure: " + t.getMessage())); isClosed.set(true); isOpen.set(false); connectLatch.get().countDown(); @@ -451,12 +463,12 @@ public void onFailure(WebSocket webSocket, Throwable t, Response response) { latch.countDown(); } log.error("WebSocket failed: " + t.getMessage()); + callback.onClose(-1, "failure: " + t.getMessage()); } @Override public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) { - this.closeCode = code; - this.closeReason = reason; + closeInfo.set(new CloseInfo(code, reason)); close(code, reason); log.debug("WebSocket closing: " + code + ", " + reason); } diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index f63995f..c96997e 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -9,6 +9,7 @@ import com.alibaba.dashscope.utils.ApiKeywords; import com.alibaba.dashscope.utils.EncryptionUtils; import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.nio.ByteBuffer; import java.util.List; @@ -29,6 +30,27 @@ public Boolean isBinaryOutput() { return output instanceof ByteBuffer; } + /** + * Parse the output field from a JsonElement in a type-safe manner. + * + *

Returns {@code null} for JsonNull, {@code JsonObject} for object elements, and the raw + * {@code JsonElement} for primitives/arrays to avoid {@code getAsJsonObject()} throwing. + * + * @param outputElement the JSON element representing the output field + * @return parsed output value, or {@code null} if the element is JSON null + */ + private Object parseOutputField(JsonElement outputElement) { + if (outputElement.isJsonNull()) { + return null; + } else if (outputElement.isJsonObject()) { + return outputElement.getAsJsonObject(); + } else { + // JsonPrimitive or JsonArray — return the raw element so downstream code + // can handle it gracefully instead of throwing getAsJsonObject(). + return outputElement; + } + } + @Override @SuppressWarnings("unchecked") protected T fromResponse(Protocol protocol, NetworkResponse response) @@ -74,10 +96,7 @@ protected T fromResponse(Protocol protocol, NetworkResponse r if (jsonObject.has(ApiKeywords.PAYLOAD)) { JsonObject payload = jsonObject.getAsJsonObject(ApiKeywords.PAYLOAD); if (payload.has(ApiKeywords.OUTPUT)) { - this.output = - payload.get(ApiKeywords.OUTPUT).isJsonNull() - ? null - : payload.get(ApiKeywords.OUTPUT); + this.output = parseOutputField(payload.get(ApiKeywords.OUTPUT)); } if (payload.has(ApiKeywords.USAGE)) { this.setUsage( @@ -96,27 +115,7 @@ protected T fromResponse(Protocol protocol, NetworkResponse r this.setStatusCode(response.getHttpStatusCode()); } if (jsonObject.has(ApiKeywords.OUTPUT)) { - if (jsonObject.get(ApiKeywords.OUTPUT).isJsonNull()) { - this.output = null; - } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonObject()) { - this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); - } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive()) { - // Server returned encrypted output but client didn't enable encryption - String outputStr = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); - if (outputStr.length() > 100 && outputStr.matches("^[A-Za-z0-9+/=]+$")) { - throw new ApiException( - Status.builder() - .statusCode(400) - .code("EncryptionMismatch") - .message( - "Server returned encrypted output but client encryption is not enabled. " - + "Please set enableEncrypt(true) in your request parameters.") - .build()); - } - this.output = jsonObject.get(ApiKeywords.OUTPUT); - } else { - this.output = jsonObject.get(ApiKeywords.OUTPUT); - } + this.output = parseOutputField(jsonObject.get(ApiKeywords.OUTPUT)); } if (jsonObject.has(ApiKeywords.USAGE)) { this.setUsage( @@ -180,30 +179,8 @@ public T fromResponse( } } else { // HTTP JsonObject jsonObject = JsonUtils.parse(response.getMessage()); - // Check if output field contains encrypted content - if (jsonObject.has(ApiKeywords.OUTPUT)) { - if (jsonObject.get(ApiKeywords.OUTPUT).isJsonNull()) { - this.output = null; - } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonObject()) { - this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); - } else if (jsonObject.get(ApiKeywords.OUTPUT).isJsonPrimitive()) { - // Server returned encrypted output but client didn't enable encryption - String outputStr = jsonObject.get(ApiKeywords.OUTPUT).getAsString(); - if (outputStr.length() > 100 && outputStr.matches("^[A-Za-z0-9+/=]+$")) { - throw new ApiException( - Status.builder() - .statusCode(400) - .code("EncryptionMismatch") - .message( - "Server returned encrypted output but client encryption is not enabled. " - + "Please set enableEncrypt(true) in your request parameters.") - .build()); - } - this.output = jsonObject.get(ApiKeywords.OUTPUT); - } else { - this.output = jsonObject.get(ApiKeywords.OUTPUT); - } - } + // Preserve original behavior: the entire JSON object is the output for flatten mode. + this.output = jsonObject; this.event = response.getEvent(); } return (T) this; @@ -236,7 +213,7 @@ public T fromResponse( req.getEncryptionConfig().getIv()); this.output = JsonUtils.parse(plainOutput); } else { - this.output = jsonObject.get(ApiKeywords.OUTPUT).getAsJsonObject(); + this.output = parseOutputField(jsonObject.get(ApiKeywords.OUTPUT)); } } else { this.output = null; @@ -282,7 +259,8 @@ public T fromResponse( return (T) this; } - // Fallback: server encrypted output but did not set X-DashScope-OutputEncrypted header + // Fallback: server encrypted output but did not set X-DashScope-OutputEncrypted header. + // Only attempt fallback decryption when encryption config is available to avoid false positives. if (protocol == Protocol.HTTP && req.getEncryptionConfig() != null) { try { JsonObject jsonObject = JsonUtils.parse(response.getMessage()); diff --git a/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java b/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java new file mode 100644 index 0000000..efabfe7 --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java @@ -0,0 +1,229 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.audio.omni; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.google.gson.JsonObject; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OmniRealtimeConversation} focusing on WebSocket lifecycle: close() + * idempotency, onFailure callback notification, and onClosing → checkStatus exception + * information. + */ +public class TestOmniRealtimeConversation { + + private static class RecordingCallback extends OmniRealtimeCallback { + final List events = new ArrayList<>(); + volatile int closeCode = Integer.MIN_VALUE; + volatile String closeReason = null; + volatile boolean openCalled = false; + final AtomicBoolean closeCalled = new AtomicBoolean(false); + + @Override + public void onOpen() { + openCalled = true; + } + + @Override + public void onEvent(JsonObject message) { + events.add(message); + } + + @Override + public void onClose(int code, String reason) { + closeCode = code; + closeReason = reason; + closeCalled.set(true); + } + } + + private OmniRealtimeConversation createConversation(RecordingCallback callback) { + OmniRealtimeParam param = + OmniRealtimeParam.builder().model("test-model").apikey("test-key").build(); + return new OmniRealtimeConversation(param, callback); + } + + private void setConnectLatch(OmniRealtimeConversation conv, CountDownLatch latch) + throws Exception { + Field f = OmniRealtimeConversation.class.getDeclaredField("connectLatch"); + f.setAccessible(true); + f.set(conv, new AtomicReference<>(latch)); + } + + private void setIsClosed(OmniRealtimeConversation conv, boolean value) throws Exception { + Field f = OmniRealtimeConversation.class.getDeclaredField("isClosed"); + f.setAccessible(true); + ((AtomicBoolean) f.get(conv)).set(value); + } + + private void setIsOpen(OmniRealtimeConversation conv, boolean value) throws Exception { + Field f = OmniRealtimeConversation.class.getDeclaredField("isOpen"); + f.setAccessible(true); + ((AtomicBoolean) f.get(conv)).set(value); + } + + private boolean getIsClosed(OmniRealtimeConversation conv) throws Exception { + Field f = OmniRealtimeConversation.class.getDeclaredField("isClosed"); + f.setAccessible(true); + return ((AtomicBoolean) f.get(conv)).get(); + } + + private boolean getIsOpen(OmniRealtimeConversation conv) throws Exception { + Field f = OmniRealtimeConversation.class.getDeclaredField("isOpen"); + f.setAccessible(true); + return ((AtomicBoolean) f.get(conv)).get(); + } + + @Test + public void testCloseIdempotent() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + + conv.close(1000, "bye"); + assertTrue(getIsClosed(conv)); + assertFalse(getIsOpen(conv)); + + // Second close — should be a no-op + conv.close(1001, "second"); + assertTrue(getIsClosed(conv)); + assertFalse(getIsOpen(conv)); + } + + @Test + public void testOnFailureCallsCallback() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setConnectLatch(conv, new CountDownLatch(1)); + + Throwable testError = new RuntimeException("connection reset"); + conv.onFailure(null, testError, null); + + assertTrue(callback.closeCalled.get()); + assertEquals(-1, callback.closeCode); + assertEquals("failure: connection reset", callback.closeReason); + assertTrue(getIsClosed(conv)); + assertFalse(getIsOpen(conv)); + } + + @Test + public void testOnClosingCheckStatusThrowsWithInfo() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setConnectLatch(conv, new CountDownLatch(1)); + setIsOpen(conv, true); + + conv.onClosing(null, 1011, "server error"); + + try { + conv.checkStatus(); + fail("checkStatus should throw RuntimeException after onClosing"); + } catch (RuntimeException e) { + String msg = e.getMessage(); + assertTrue(msg.contains("already closed")); + assertTrue(msg.contains("1011")); + assertTrue(msg.contains("server error")); + } + } + + @Test + public void testCheckStatusNotConnected() { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + + try { + conv.checkStatus(); + fail("checkStatus should throw when not connected"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("not connected")); + } + } + + @Test + public void testCheckStatusClosedNoInfo() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setIsClosed(conv, true); + + try { + conv.checkStatus(); + fail("checkStatus should throw when closed"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("already closed")); + assertFalse(e.getMessage().contains("code=")); + } + } + + @Test + public void testConnectThrowsWhenClosed() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setIsClosed(conv, true); + + try { + conv.connect(); + fail("connect() should throw when already closed"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("already closed")); + } catch (Exception e) { + fail("Expected RuntimeException, got: " + e.getClass().getName()); + } + } + + @Test + public void testConnectThrowsWhenAlreadyOpen() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setIsOpen(conv, true); + + try { + conv.connect(); + fail("connect() should throw when already connected"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("already connected")); + } catch (Exception e) { + fail("Expected RuntimeException, got: " + e.getClass().getName()); + } + } + + @Test + public void testOnClosedCallsCallback() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setConnectLatch(conv, new CountDownLatch(1)); + setIsOpen(conv, true); + + conv.onClosed(null, 1000, "normal closure"); + + assertTrue(callback.closeCalled.get()); + assertEquals(1000, callback.closeCode); + assertEquals("normal closure", callback.closeReason); + assertTrue(getIsClosed(conv)); + assertFalse(getIsOpen(conv)); + } + + @Test + public void testOnFailureReleasesDisconnectLatch() throws Exception { + RecordingCallback callback = new RecordingCallback(); + OmniRealtimeConversation conv = createConversation(callback); + setConnectLatch(conv, new CountDownLatch(1)); + + CountDownLatch disconnectLatch = new CountDownLatch(1); + Field f = OmniRealtimeConversation.class.getDeclaredField("disconnectLatch"); + f.setAccessible(true); + f.set(conv, new AtomicReference<>(disconnectLatch)); + + conv.onFailure(null, new RuntimeException("test failure"), null); + + assertEquals(0, disconnectLatch.getCount()); + } +} diff --git a/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java b/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java new file mode 100644 index 0000000..b1b813c --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java @@ -0,0 +1,324 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 com.alibaba.dashscope.base.HalfDuplexParamBase; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.protocol.HalfDuplexRequest; +import com.alibaba.dashscope.protocol.HttpMethod; +import com.alibaba.dashscope.protocol.NetworkResponse; +import com.alibaba.dashscope.protocol.Protocol; +import com.alibaba.dashscope.protocol.ServiceOption; +import com.alibaba.dashscope.protocol.StreamingMode; +import com.alibaba.dashscope.utils.EncryptionConfig; +import com.alibaba.dashscope.utils.EncryptionUtils; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.crypto.SecretKey; +import lombok.experimental.SuperBuilder; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DashScopeResult} focusing on output field parsing type-safety, flatten + * mode behavior, and encryption fallback decryption. + */ +public class TestDashScopeResult { + + private NetworkResponse buildHttpResponse(String body) { + return NetworkResponse.builder() + .message(body) + .headers(new HashMap<>()) + .httpStatusCode(200) + .build(); + } + + private NetworkResponse buildHttpResponse(String body, Map> headers) { + return NetworkResponse.builder() + .message(body) + .headers(headers) + .httpStatusCode(200) + .build(); + } + + @Test + public void testOutputAsJsonObject() throws ApiException { + String json = "{\"output\":{\"text\":\"hello\"},\"request_id\":\"req-1\"}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + JsonObject output = (JsonObject) result.getOutput(); + assertEquals("hello", output.get("text").getAsString()); + assertEquals("req-1", result.getRequestId()); + } + + @Test + public void testOutputAsJsonPrimitiveNoThrow() throws ApiException { + String json = "{\"output\":\"some-plain-string\",\"request_id\":\"req-2\"}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonElement); + assertEquals("some-plain-string", ((JsonElement) result.getOutput()).getAsString()); + } + + @Test + public void testOutputAsJsonNull() throws ApiException { + String json = "{\"output\":null,\"request_id\":\"req-3\"}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp); + + assertNull(result.getOutput()); + } + + @Test + public void testOutputFieldAbsent() throws ApiException { + String json = "{\"request_id\":\"req-4\",\"code\":\"0\",\"message\":\"ok\"}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp); + + assertNull(result.getOutput()); + assertEquals("req-4", result.getRequestId()); + assertEquals("0", result.getCode()); + } + + @Test + public void testIsFlattenHttpReturnsEntireJson() throws ApiException { + String json = + "{\"output\":{\"text\":\"hello\"},\"request_id\":\"req-5\",\"usage\":{\"total\":10}}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp, true); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + JsonObject output = (JsonObject) result.getOutput(); + assertTrue(output.has("output")); + assertTrue(output.has("request_id")); + assertTrue(output.has("usage")); + assertEquals("hello", output.getAsJsonObject("output").get("text").getAsString()); + } + + @Test + public void testIsFlattenWebSocketReturnsEntireJson() throws ApiException { + String json = + "{\"header\":{\"task_id\":\"task-1\"},\"payload\":{\"output\":{\"text\":\"hi\"}}}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.WEBSOCKET, resp, true); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + JsonObject output = (JsonObject) result.getOutput(); + assertTrue(output.has("header")); + assertTrue(output.has("payload")); + } + + @Test + public void testWebSocketNonFlattenOutput() throws ApiException { + String json = + "{\"header\":{\"task_id\":\"task-2\",\"status_code\":200}," + + "\"payload\":{\"output\":{\"text\":\"ws-hello\"}}}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.WEBSOCKET, resp); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + assertEquals("ws-hello", ((JsonObject) result.getOutput()).get("text").getAsString()); + assertEquals("task-2", result.getRequestId()); + assertEquals(Integer.valueOf(200), result.getStatusCode()); + } + + @Test + public void testOutputWithDataField() throws ApiException { + String json = "{\"data\":{\"key\":\"val\"},\"request_id\":\"req-8\"}"; + NetworkResponse resp = buildHttpResponse(json); + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + JsonObject output = (JsonObject) result.getOutput(); + assertTrue(output.has("data")); + assertFalse(output.has("request_id")); + } + + @Test + public void testEncryptionFallbackDecryption() throws Exception { + SecretKey aesKey = EncryptionUtils.generateAESKey(); + byte[] iv = new byte[12]; + new java.security.SecureRandom().nextBytes(iv); + + String plainOutput = "{\"text\":\"decrypted-content\"}"; + String encryptedOutput = EncryptionUtils.AESEncrypt(plainOutput, aesKey, iv); + + String json = "{\"output\":\"" + encryptedOutput + "\",\"request_id\":\"req-9\"}"; + NetworkResponse resp = buildHttpResponse(json); + + HalfDuplexRequest req = buildTestHalfDuplexRequest(false, aesKey, iv); + + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp, false, req); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + assertEquals("decrypted-content", ((JsonObject) result.getOutput()).get("text").getAsString()); + assertEquals("req-9", result.getRequestId()); + } + + @Test + public void testNoFallbackWhenConfigNull() throws Exception { + String base64LikeString = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + String json = "{\"output\":\"" + base64LikeString + "\",\"request_id\":\"req-10\"}"; + NetworkResponse resp = buildHttpResponse(json); + + HalfDuplexRequest req = buildTestHalfDuplexRequest(false, null, null); + + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp, false, req); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonElement); + assertEquals(base64LikeString, ((JsonElement) result.getOutput()).getAsString()); + } + + @Test + public void testEncryptionWithHeader() throws Exception { + SecretKey aesKey = EncryptionUtils.generateAESKey(); + byte[] iv = new byte[12]; + new java.security.SecureRandom().nextBytes(iv); + + String plainOutput = "{\"text\":\"header-decrypted\"}"; + String encryptedOutput = EncryptionUtils.AESEncrypt(plainOutput, aesKey, iv); + + String json = "{\"output\":\"" + encryptedOutput + "\",\"request_id\":\"req-11\"}"; + + Map> headers = new HashMap<>(); + headers.put("x-dashscope-outputencrypted", Arrays.asList("true")); + NetworkResponse resp = buildHttpResponse(json, headers); + + HalfDuplexRequest req = buildTestHalfDuplexRequest(true, aesKey, iv); + + DashScopeResult result = new DashScopeResult(); + result.fromResponse(Protocol.HTTP, resp, false, req); + + assertNotNull(result.getOutput()); + assertTrue(result.getOutput() instanceof JsonObject); + assertEquals("header-decrypted", ((JsonObject) result.getOutput()).get("text").getAsString()); + } + + // ---- Helpers ---- + + @SuperBuilder + private static class TestParamBase extends HalfDuplexParamBase { + @Override + public String getModel() { + return "test-model"; + } + + @Override + public Map getParameters() { + return new HashMap<>(); + } + + @Override + public Map getHeaders() { + return new HashMap<>(); + } + + @Override + public JsonObject getHttpBody() { + return new JsonObject(); + } + + @Override + public Object getInput() { + return null; + } + + @Override + public Object getResources() { + return null; + } + + @Override + public ByteBuffer getBinaryData() { + return null; + } + + @Override + public void validate() {} + } + + private static class TestServiceOption implements ServiceOption { + @Override + public StreamingMode getStreamingMode() { + return null; + } + + @Override + public Protocol getProtocol() { + return Protocol.HTTP; + } + + @Override + public HttpMethod getHttpMethod() { + return HttpMethod.POST; + } + + @Override + public String httpUrl() { + return "/test"; + } + + @Override + public String getBaseHttpUrl() { + return null; + } + + @Override + public String getBaseWebSocketUrl() { + return null; + } + } + + private HalfDuplexRequest buildTestHalfDuplexRequest( + boolean enableEncrypt, SecretKey aesKey, byte[] iv) throws Exception { + TestParamBase param = TestParamBase.builder().enableEncrypt(enableEncrypt).build(); + + HalfDuplexRequest req = new HalfDuplexRequest(param, new TestServiceOption()); + + if (aesKey != null) { + EncryptionConfig config = + EncryptionConfig.builder() + .publicKeyId("test-key-id") + .base64PublicKey("test-public-key") + .AESEncryptKey(aesKey) + .iv(iv) + .build(); + Field f = HalfDuplexRequest.class.getDeclaredField("encryptionConfig"); + f.setAccessible(true); + f.set(req, config); + } + return req; + } +} diff --git a/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java b/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java new file mode 100644 index 0000000..fa6e16c --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java @@ -0,0 +1,56 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.dashscope.aigc.conversation.ConversationResult; +import com.alibaba.dashscope.aigc.generation.GenerationResult; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.junit.jupiter.api.Test; + +/** + * Tests that Result subclass {@code fromDashScopeResult} methods handle non-JsonObject output + * (e.g. JsonPrimitive from encrypted or malformed responses) gracefully via the {@code + * instanceof JsonObject} defensive check. + */ +public class TestResultTypeSafety { + + private DashScopeResult buildResultWithPrimitiveOutput() { + DashScopeResult dsr = new DashScopeResult(); + dsr.setRequestId("req-ts-1"); + dsr.setOutput(new JsonPrimitive("not-a-json-object")); + return dsr; + } + + @Test + public void testGenerationResultWithPrimitiveOutput() { + DashScopeResult dsr = buildResultWithPrimitiveOutput(); + GenerationResult result = GenerationResult.fromDashScopeResult(dsr); + assertNull(result.getOutput()); + assertEquals("req-ts-1", result.getRequestId()); + } + + @Test + public void testConversationResultWithPrimitiveOutput() { + DashScopeResult dsr = buildResultWithPrimitiveOutput(); + ConversationResult result = ConversationResult.fromDashScopeResult(dsr); + assertNull(result.getOutput()); + assertEquals("req-ts-1", result.getRequestId()); + } + + @Test + public void testGenerationResultWithJsonObjectOutput() { + DashScopeResult dsr = new DashScopeResult(); + dsr.setRequestId("req-ts-2"); + JsonObject outputJson = JsonUtils.parse("{\"choices\":[],\"text\":\"hello\"}"); + dsr.setOutput(outputJson); + + GenerationResult result = GenerationResult.fromDashScopeResult(dsr); + assertTrue(result.getOutput() != null); + assertEquals("req-ts-2", result.getRequestId()); + } +} From 0d908f5234918c67155dc022934788493cbe8a02 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 2 Jul 2026 15:40:26 +0800 Subject: [PATCH 11/11] fix: apply google-java-format to fix CI lint errors --- 1/MultiModalStreamCallTests.java | 245 ++++++++++++++++++ 1/TestQwen3VlPlusFullOutput.class | Bin 0 -> 6801 bytes 1/TestQwen3VlPlusFullOutput.java | 82 ++++++ .../dashscope/common/DashScopeResult.java | 3 +- .../omni/TestOmniRealtimeConversation.java | 3 +- .../dashscope/common/TestDashScopeResult.java | 10 +- .../common/TestResultTypeSafety.java | 6 +- 7 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 1/MultiModalStreamCallTests.java create mode 100644 1/TestQwen3VlPlusFullOutput.class create mode 100644 1/TestQwen3VlPlusFullOutput.java diff --git a/1/MultiModalStreamCallTests.java b/1/MultiModalStreamCallTests.java new file mode 100644 index 0000000..80249d7 --- /dev/null +++ b/1/MultiModalStreamCallTests.java @@ -0,0 +1,245 @@ +package com.example.linkcheck4j; + +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult; +import com.alibaba.dashscope.common.MultiModalMessage; +import com.alibaba.dashscope.common.Role; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.exception.UploadFileException; +import io.reactivex.Flowable; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +class MultiModalStreamCallTests { + + private static final Logger logger = LoggerFactory.getLogger(MultiModalStreamCallTests.class); + + @Value("${spring.ai.dashscope.api-key}") + private String apiKey; + + private static final String IMAGE_URL = + "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg"; + + // ==================== 测试用例 ==================== + + /** + * 场景1: qwen3-vl-plus 开启思考 + 增量输出(true) + * 预期: 正常增量输出,每次回调只返回新增片段 + */ + @Test + void qwen3VlPlus_thinking_incrementalOutputTrue_shouldSucceed() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen3-vl-plus", true, true, + "qwen3-vl-plus | 开思考 | 增量输出(true)"); + assertSuccessfulOutput(result); + assertIncrementalPattern(result, "qwen3-vl-plus 开思考增量输出"); + } + + /** + * 场景2: qwen3-vl-plus 开启思考 + 全量输出(false)" + * 实测: qwen3 系列开思考时不支持全量,实际仍按增量返回 + */ + @Test + void qwen3VlPlus_thinking_incrementalOutputFalse_shouldFallbackToIncremental() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen3-vl-plus", true, false, + "qwen3-vl-plus | 开思考 | 全量输出(false) -> 实际仍为增量"); + assertSuccessfulOutput(result); + } + + /** + * 场景3: qwen3-vl-plus 不开思考 + 增量输出(true) + * 预期: 正常增量输出 + */ + @Test + void qwen3VlPlus_noThinking_incrementalOutputTrue_shouldSucceed() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen3-vl-plus", false, true, + "qwen3-vl-plus | 不开思考 | 增量输出(true)"); + assertSuccessfulOutput(result); + assertIncrementalPattern(result, "qwen3-vl-plus 不开思考增量输出"); + } + + /** + * 场景4: qwen3-vl-plus 不开思考 + 全量输出(false)" + * 实测: 不开启思考时设置全量输出不会生效 + */ + @Test + void qwen3VlPlus_noThinking_incrementalOutputFalse_fullOutputNotEffective() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen3-vl-plus", false, false, + "qwen3-vl-plus | 不开思考 | 全量输出(false) -> 不生效"); + assertSuccessfulOutput(result); + } + + /** + * 场景5: qwen-vl-plus 增量输出(true) + * 预期: 正常增量输出 + */ + @Test + void qwenVlPlus_incrementalOutputTrue_shouldSucceed() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen-vl-plus", false, true, + "qwen-vl-plus | 增量输出(true)"); + assertSuccessfulOutput(result); + assertIncrementalPattern(result, "qwen-vl-plus 增量输出"); + } + + /** + * 场景6: qwen-vl-plus 全量输出(false)" + * 实测: 可以成功全量输出,每次回调返回累积完整内容 + */ + @Test + void qwenVlPlus_incrementalOutputFalse_shouldBeFullOutput() + throws ApiException, NoApiKeyException, UploadFileException { + StreamResult result = doStreamCall("qwen-vl-plus", false, false, + "qwen-vl-plus | 全量输出(false) -> 正常"); + assertSuccessfulOutput(result); + assertFullOutputPattern(result, "qwen-vl-plus 全量输出"); + } + + // ==================== 公共方法 ==================== + + /** 构建多模态用户消息(图片 + 文本) */ + private MultiModalMessage buildUserMessage(String text) { + return MultiModalMessage.builder() + .role(Role.USER.getValue()) + .content(Arrays.asList( + Collections.singletonMap("image", IMAGE_URL), + Collections.singletonMap("text", text))) + .build(); + } + + /** + * 执行流式调用并打印结果 + * + * @param model 模型名称 + * @param enableThinking 是否开启思考 + * @param incrementalOutput true=增量输出 false=全量输出 + * @param label 测试场景标签(用于打印区分) + * @return 流式调用结果封装 + */ + private StreamResult doStreamCall(String model, boolean enableThinking, boolean incrementalOutput, String label) + throws ApiException, NoApiKeyException, UploadFileException { + System.out.println("\n=================================================="); + System.out.println("[" + label + "]"); + System.out.println(" model: " + model); + System.out.println(" enableThinking: " + enableThinking); + System.out.println(" incrementalOutput: " + incrementalOutput); + System.out.println("--------------------------------------------------"); + + MultiModalConversation conv = new MultiModalConversation(); + MultiModalMessage userMessage = buildUserMessage("图中描绘的是什么景象?"); + + var builder = MultiModalConversationParam.builder() + .apiKey(apiKey) + .model(model) + .messages(Arrays.asList(userMessage)) + .incrementalOutput(incrementalOutput); + if (enableThinking) { + builder.enableThinking(true); + } + MultiModalConversationParam param = builder.build(); + + List fragments = new ArrayList<>(); + AtomicInteger printedLength = new AtomicInteger(0); + Flowable result = conv.streamCall(param); + result.blockingForEach(item -> { + try { + var content = item.getOutput().getChoices().get(0).getMessage().getContent(); + if (content != null && !content.isEmpty()) { + Object textObj = content.get(0).get("text"); + String text = textObj == null ? "" : textObj.toString(); + if (!text.isEmpty()) { + fragments.add(text); + // 打印原始片段,用于直观判断 SDK 实际返回的是增量还是全量 + String preview = text.length() > 80 + ? text.substring(0, 80).replace("\n", "\\n") + "..." + : text.replace("\n", "\\n"); + System.out.println("[原始片段 " + fragments.size() + "] len=" + text.length() + " -> " + preview); + if (incrementalOutput) { + // 增量模式:直接打印每次返回的片段 + System.out.print(text); + } else { + // 全量模式:只打印相比上次新增的部分 + int prev = printedLength.get(); + if (text.length() > prev) { + System.out.print(text.substring(prev)); + printedLength.set(text.length()); + } + } + } + } + } catch (Exception e) { + logger.warn("Parse item failed: {}", e.getMessage()); + } + }); + System.out.println("\n--------------------------------------------------"); + System.out.println("[汇总] 场景: " + label); + System.out.println(" 原始片段总数: " + fragments.size()); + if (!fragments.isEmpty()) { + System.out.println(" 第一个片段长度: " + fragments.get(0).length()); + System.out.println(" 最后一个片段长度: " + fragments.get(fragments.size() - 1).length()); + // 判断长度是否单调不减(全量特征)或普遍很小(增量特征) + boolean monotonicNonDecreasing = true; + int increases = 0; + for (int i = 1; i < fragments.size(); i++) { + if (fragments.get(i).length() < fragments.get(i - 1).length()) { + monotonicNonDecreasing = false; + } else if (fragments.get(i).length() > fragments.get(i - 1).length()) { + increases++; + } + } + System.out.println(" 长度单调不减: " + monotonicNonDecreasing + " (增长次数=" + increases + ")"); + System.out.println(" 判定: " + (monotonicNonDecreasing && increases > fragments.size() / 2 ? "全量返回" : "增量返回")); + } + System.out.println("==================================================\n"); + + String completeText = incrementalOutput + ? String.join("", fragments) + : (fragments.isEmpty() ? "" : fragments.get(fragments.size() - 1)); + return new StreamResult(fragments, completeText, !fragments.isEmpty(), incrementalOutput); + } + + private void assertSuccessfulOutput(StreamResult result) { + assertTrue(result.hasContent(), "流式调用应返回非空内容"); + assertFalse(result.completeText().isBlank(), "最终输出文本不应为空"); + } + + private void assertIncrementalPattern(StreamResult result, String message) { + assertTrue(result.incrementalOutput(), "该断言仅适用于 incrementalOutput=true 的场景"); + String concatenated = String.join("", result.fragments()); + assertEquals(result.completeText(), concatenated, + message + ":增量片段拼接后应等于完整回复"); + } + + private void assertFullOutputPattern(StreamResult result, String message) { + assertFalse(result.incrementalOutput(), "该断言仅适用于 incrementalOutput=false 的场景"); + List fragments = result.fragments(); + for (int i = 1; i < fragments.size(); i++) { + assertTrue(fragments.get(i).length() >= fragments.get(i - 1).length(), + message + ":全量输出片段长度应单调不减"); + } + assertEquals(fragments.get(fragments.size() - 1), result.completeText(), + message + ":最后一个片段应等于完整回复"); + } + + private record StreamResult(List fragments, String completeText, boolean hasContent, boolean incrementalOutput) { + } +} diff --git a/1/TestQwen3VlPlusFullOutput.class b/1/TestQwen3VlPlusFullOutput.class new file mode 100644 index 0000000000000000000000000000000000000000..e2e80ea34e20833412dd02fbb3e3d4edee7a7fc0 GIT binary patch literal 6801 zcmcIpdwdkt75;8^H#35p)8z;MyWMFn?otX^@ zwXJQf_0`%|+k#qKtu3vswa|7ABGkUrTKj&#wMDcKYu~L_#dBvi*$r&?fxmSBn7MQB zx#ymH&UYU7?vt-Qd;~x}J5NF@c8J(1K}S-;au_AlGDV~$Tn$Tv%|{#oX$cZ`NH_-r zCD?^Qi5^1|cH_+gt`YGT0dE!YHVN0_?IPYG;++z%!@D@a_5AT}2{+(I3A1sNfSX0U zmp5+}@IL;2KYx6HFMOMb+XZ}3LM2~I!iRz+=3Np#g1be0RKUk1)Npz4;qQA(@p0V8 zTc6;MPx4QLKlTau6yM9IMcmIvJ|p6@A|4R&IS~&^_&oMYIDjt*_@abW$nZ@)REjbT zi#W&?;&OUe!XZ2&;;?`t626Q_ImfSv_^ODn@#fdLg1#Z(F%geTh~f!`lD6oY^)207 zHnlSZ+jl4fN<>$TzDUfmG^4MHVN$bc*p6a2TNOR6G8C`W49!{1;HzG=mBHU^Ca8Bx zyJn~z>12;;ZB}}8>hUKP&0v^Oz5QgaeBz{-qr`Vcm6S_QyBF|HG8w5?z>@;LCEzIm z-zN1jNG*eLHRWg|2+h-#WKTk=f zj+-epqG)~bNHVQETGC7?dfYSyRLfR4+enmmM|n5xcC>qwYEysHqOGJ(QW^;oGt*XF zZPoY&=WbSQ=aOBjv3#rErKj!Iw61riomASX!)Au5>(<0J#F{(1TDsSCwRLZ7 z*(TsSGQNxN$@o5gAmfMl5kq8)t@NplAwDrQF5l2fC8dRTsza+uGctaRpD@&RS|M9? zLMJT~QjG!4GL58aIH3W>(zsb_W&9LBW7sfJDw6SY{8GTLWc(Vxk?~tRE#Mg$zr*jz zQZAZYyO5a~0$P$b$xt!w1{u_T$4S|Zkx0L)r^19gM~jEamP2VHoG|0HUNdgj@(uKo z9l}Y)Ag*EFwj;ay!_6I$x}|mX%a+#FMbzYwwSzEV_H`>pqPtJC^x7RMwNJoN8PDPv zq0~_a9fo<&-g)Hc@B_zgz4f_6d!D=Fx?_7DJbLKbqX%y|w)es3_uup4Bjm@=$#@=r zknuPB;{x33K#NQcG6Q$BF#Zr=$g%RnFtxaosLbVv?pAM1SW*Dl~tSh#y zlKYfiO^6K-=%lwn8UMh)2||S>r{3fk*NCb%N2!ee;J;i0FUfeBgLf7ELY(b#&NoG_ z5V|I$8F5SH*i*7jv8q?mZIwoB$5NGKv!d%EnkX?$%i&~=Whq14iDmo`uP~IYHBE;m zD5FV$jrz5L@kBG;ZQ7{Oe)q;h}sM@UEX;SZB%)a zH=86|DBhnocG`_0nFUxeg&mm*OynOWGL!JM%u1O|oKIvUvq?;3SlF%_eNMj}B2Af& zX=riU)=Uly|HusxaoV>Nz zYsjn|FUV{Lo6c1-lYx{!i&Y5hESa6nW|LQMcU2jxN?aMzRby3EA{1U7s!ZqAz^d}9H0}zN*%Q7vl)^a>e zqy;hzLv*6_;rblIuw3 zKsng24r;U$e{~zr$U@IZV40N*ELo=?b(@XVi^j>Xrc7lNgE$mdQzL^%6DZU<(^vD(_e)IF3$n4 z+0=FN9DiaKs~U6C(_=VeWK`_J}dRWg#4iM{8WZigFvDMu75wg>U5F~wT} z7H+*o$f072piEHOo|Ph3jZGw60xm)s>dqL1v{7f=NoO7-Pd7)?vc*)hZB&N@b}qwN z8x*~FT{@+!gI*?AY<1MLy7LW~Ex*T8UJp%-6kt|apf9@si4vf~SJeeZiDpiYFs51q z6zM&IMk0Xv#)Zk}mnTntcTOZR=@k`(x|{N$lS4X1!onW=f3?~r6Aczpzt2pQhe)~l zhoQfEVmC+1Dwvg;Bv!)~pO6Sf{CTT{AX~x^n>b-cPfi^FaejDXSe|cRH*lOFY;W4| zVq1hCInzniBBf2~(arcyigc}})uP1v87iyC?yNx!Y_ zrn-lYw`~lwDK+LrQO_-Nw4!5rT|`frhbwpteOARJ4V6SxC~}6EpNe6}L}A^t0G;ni zEU%(t*ffEl_IQ$kCQRZHDJ7EFt2rqs1WDvv{>!EtPLVAo$e%d*NctI5NsZt;&wiu! z@F@v`#+bTjgq-Rsuhw(Rv8ckW=1cOsj6ii;8wbVI9C+F&@AdJsm@vSvM>>1?jmH=W zWGP5fC3d=poS(4l5{;qqP((z6rKWTxP5@k&kK5*c#fqs{r&S}a=BM>B{U-y97#6!# zAj!2dpI>&p{K|P`|DL)|s3rwQhpD#0;CutgNMN-Lou|bvKX05mod_%vD(Eh^SalesT}tstAUo950mU5PxE zZ<8zo@1(ndE-dIqqtc_yH+j}{d8i&UaWfq*^T|(t&|C7X}1tI*cMv^AOm6cfwYB z7Q3C3QG!cx8BHV7qWh`0ggD+(bL4K6)cET5K@1iR!`Hs}F-VIu@bAUU0|*3*GZ6S& zq_=+xrpLDVgXO^)yfuuO8O+L{qGK3m z5zn(9#q8jm4Cd0?e;DU%^MyT{PzLiF{Rc2VxPX>9gi2bXsxh#n!k@vy#^Q?NionA- z7s#NR6D@LOntup2M7KD&B!h58;2_A2GKgfbl!p2DGKM?wfWJZTH;Cn8mf~L2RRqe# zgQzRQqgb9nJ^%D4@;dHCiLXJ}hmz|=w(s~8wBpE0D`=&1A%g~Qk-U-f+>yPc#Pj!& zwl1L0lV}=UIkR%)ub3s}7Apd`*iD#E&*_+oa=KZcLAU8Mu>`ZQ6lddn>RC;u?x0)m z%Mij9n2R2QNG}#(2T@vBNSFBM;(9E?%~*`v5XN1o#eIm-&G}jkqnYkKTks4)>L}LJ zwbuqV18uAV7qi(|&*me_R-%Jlg-&)efo&T}at)-?bpkGb1*P83tT68MQaS|;&m<{_BX zLL>|W2MJhB8LaeWEx0O!)frqEUGf;NU6KXCt|cR*Wx?`;xTpvn;m65VYxY4P<*#k@ zbENt2Ku1l5{~(%+a4GMoFY6k{x`qJ9Tubp`v~Kg&xG)>WdV;x2;x+>neqVVYgAKv9 z;KdnijAhV%={|&_m6FxpRxZgjgNGmq@JLgsorD_g~pU{EgoKWYzeWzl1Gg^{fsR R_%O|riEa<^+DlG$=j**m8chHI literal 0 HcmV?d00001 diff --git a/1/TestQwen3VlPlusFullOutput.java b/1/TestQwen3VlPlusFullOutput.java new file mode 100644 index 0000000..806a6e8 --- /dev/null +++ b/1/TestQwen3VlPlusFullOutput.java @@ -0,0 +1,82 @@ +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult; +import com.alibaba.dashscope.common.MultiModalMessage; +import com.alibaba.dashscope.common.Role; +import io.reactivex.Flowable; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.ArrayList; + +public class TestQwen3VlPlusFullOutput { + + private static final String IMAGE_URL = + "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg"; + + public static void main(String[] args) throws Exception { + String apiKey = args.length > 0 ? args[0] : System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + System.err.println("Usage: java TestQwen3VlPlusFullOutput "); + System.err.println("Or set DASHSCOPE_API_KEY environment variable."); + System.exit(1); + } + + MultiModalConversation conv = new MultiModalConversation(); + MultiModalMessage userMessage = MultiModalMessage.builder() + .role(Role.USER.getValue()) + .content(Arrays.asList( + Collections.singletonMap("image", IMAGE_URL), + Collections.singletonMap("text", "图中描绘的是什么景象?"))) + .build(); + + MultiModalConversationParam param = MultiModalConversationParam.builder() + .apiKey(apiKey) + //.model("qwen3-vl-plus") + .model("qwen-vl-max") + .messages(Arrays.asList(userMessage)) + //.incrementalOutput(false) + .build(); + + System.out.println("=== qwen3-vl-plus | incrementalOutput=false | streamCall ===\n"); + + List fragments = new ArrayList<>(); + Flowable result = conv.streamCall(param); + result.blockingForEach(item -> { + try { + List> content = item.getOutput().getChoices().get(0).getMessage().getContent(); + if (content != null && !content.isEmpty()) { + Object textObj = content.get(0).get("text"); + String text = textObj == null ? "" : textObj.toString(); + if (!text.isEmpty()) { + fragments.add(text); + int preview = Math.min(text.length(), 80); + System.out.printf("[chunk %d] len=%d -> %s%n", + fragments.size(), text.length(), + text.substring(0, preview).replace("\n", "\\n")); + } + } + } catch (Exception e) { + System.err.println("Parse error: " + e.getMessage()); + } + }); + + System.out.println("\n--- Summary ---"); + System.out.println("Total chunks: " + fragments.size()); + + if (!fragments.isEmpty()) { + boolean monotonic = true; + for (int i = 1; i < fragments.size(); i++) { + if (fragments.get(i).length() < fragments.get(i - 1).length()) { + monotonic = false; + break; + } + } + System.out.println("Lengths monotonically non-decreasing: " + monotonic); + System.out.println("Output type: " + (monotonic ? "FULL (expected)" : "INCREMENTAL (unexpected)")); + System.out.println("\nFinal text:\n" + fragments.get(fragments.size() - 1)); + } + } +} diff --git a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java index c96997e..0cfe7cd 100644 --- a/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java +++ b/src/main/java/com/alibaba/dashscope/common/DashScopeResult.java @@ -260,7 +260,8 @@ public T fromResponse( } // Fallback: server encrypted output but did not set X-DashScope-OutputEncrypted header. - // Only attempt fallback decryption when encryption config is available to avoid false positives. + // Only attempt fallback decryption when encryption config is available to avoid false + // positives. if (protocol == Protocol.HTTP && req.getEncryptionConfig() != null) { try { JsonObject jsonObject = JsonUtils.parse(response.getMessage()); diff --git a/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java b/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java index efabfe7..51d80f9 100644 --- a/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java +++ b/src/test/java/com/alibaba/dashscope/audio/omni/TestOmniRealtimeConversation.java @@ -17,8 +17,7 @@ /** * Unit tests for {@link OmniRealtimeConversation} focusing on WebSocket lifecycle: close() - * idempotency, onFailure callback notification, and onClosing → checkStatus exception - * information. + * idempotency, onFailure callback notification, and onClosing → checkStatus exception information. */ public class TestOmniRealtimeConversation { diff --git a/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java b/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java index b1b813c..26ae3b2 100644 --- a/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java +++ b/src/test/java/com/alibaba/dashscope/common/TestDashScopeResult.java @@ -30,8 +30,8 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for {@link DashScopeResult} focusing on output field parsing type-safety, flatten - * mode behavior, and encryption fallback decryption. + * Unit tests for {@link DashScopeResult} focusing on output field parsing type-safety, flatten mode + * behavior, and encryption fallback decryption. */ public class TestDashScopeResult { @@ -44,11 +44,7 @@ private NetworkResponse buildHttpResponse(String body) { } private NetworkResponse buildHttpResponse(String body, Map> headers) { - return NetworkResponse.builder() - .message(body) - .headers(headers) - .httpStatusCode(200) - .build(); + return NetworkResponse.builder().message(body).headers(headers).httpStatusCode(200).build(); } @Test diff --git a/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java b/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java index fa6e16c..2a48894 100644 --- a/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java +++ b/src/test/java/com/alibaba/dashscope/common/TestResultTypeSafety.java @@ -13,9 +13,9 @@ import org.junit.jupiter.api.Test; /** - * Tests that Result subclass {@code fromDashScopeResult} methods handle non-JsonObject output - * (e.g. JsonPrimitive from encrypted or malformed responses) gracefully via the {@code - * instanceof JsonObject} defensive check. + * Tests that Result subclass {@code fromDashScopeResult} methods handle non-JsonObject output (e.g. + * JsonPrimitive from encrypted or malformed responses) gracefully via the {@code instanceof + * JsonObject} defensive check. */ public class TestResultTypeSafety {