diff --git a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeRequest.java b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeRequest.java index 7e0ff028a..af43b1947 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeRequest.java +++ b/agentscope-core/src/main/java/io/agentscope/core/formatter/dashscope/dto/DashScopeRequest.java @@ -15,8 +15,10 @@ */ package io.agentscope.core.formatter.dashscope.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import io.agentscope.core.model.EndpointType; /** * DashScope API request DTO. @@ -53,12 +55,23 @@ public class DashScopeRequest { @JsonProperty("parameters") private DashScopeParameters parameters; - public DashScopeRequest() {} + /** + * The endpoint type for endpoint selection (not serialized to JSON). + * + *
This is an internal field used to determine which DashScope API endpoint to use. + * It does not get sent to the API. + */ + @JsonIgnore private EndpointType endpointType; + + public DashScopeRequest() { + this.endpointType = EndpointType.AUTO; + } public DashScopeRequest(String model, DashScopeInput input, DashScopeParameters parameters) { this.model = model; this.input = input; this.parameters = parameters; + this.endpointType = EndpointType.AUTO; } public String getModel() { @@ -85,6 +98,24 @@ public void setParameters(DashScopeParameters parameters) { this.parameters = parameters; } + /** + * Gets the endpoint type for endpoint selection. + * + * @return the endpoint type (defaults to AUTO) + */ + public EndpointType getEndpointType() { + return endpointType; + } + + /** + * Sets the endpoint type for endpoint selection. + * + * @param endpointType the endpoint type + */ + public void setEndpointType(EndpointType endpointType) { + this.endpointType = endpointType; + } + public static Builder builder() { return new Builder(); } @@ -93,6 +124,7 @@ public static class Builder { private String model; private DashScopeInput input; private DashScopeParameters parameters; + private EndpointType endpointType = EndpointType.AUTO; public Builder model(String model) { this.model = model; @@ -109,8 +141,21 @@ public Builder parameters(DashScopeParameters parameters) { return this; } + /** + * Sets the endpoint type for endpoint selection. + * + * @param endpointType the endpoint type + * @return this builder + */ + public Builder endpointType(EndpointType endpointType) { + this.endpointType = endpointType; + return this; + } + public DashScopeRequest build() { - return new DashScopeRequest(model, input, parameters); + DashScopeRequest request = new DashScopeRequest(model, input, parameters); + request.setEndpointType(endpointType); + return request; } } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeChatModel.java b/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeChatModel.java index 6d381647d..4991665c4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeChatModel.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeChatModel.java @@ -37,11 +37,8 @@ *
This implementation uses direct HTTP calls to DashScope API via OkHttp, * without depending on the DashScope Java SDK. * - *
Supports both text and vision models through automatic endpoint routing: - *
Supports both text and vision models through automatic endpoint routing. + * Use {@link EndpointType} to explicitly control the endpoint selection. * *
Features: *
This constructor maintains backward compatibility. API type defaults to AUTO,
+ * which detects the endpoint based on model name.
+ *
+ * @param apiKey the API key for DashScope authentication
+ * @param modelName the model name (e.g., "qwen-max", "qwen-vl-plus")
+ * @param stream whether streaming should be enabled (ignored if enableThinking is true)
+ * @param enableThinking whether thinking mode should be enabled (null for disabled)
+ * @param enableSearch whether search enhancement should be enabled (null for disabled)
+ * @param defaultOptions default generation options (null for defaults)
+ * @param baseUrl custom base URL for DashScope API (null for default)
+ * @param formatter the message formatter to use (null for default DashScope formatter)
+ * @param httpTransport custom HTTP transport (null for default from factory)
+ * @param publicKeyId the RSA public key ID for encryption (null to disable encryption)
+ * @param publicKey the RSA public key for encryption (Base64-encoded, null to disable encryption)
+ */
+ public DashScopeChatModel(
+ String apiKey,
+ String modelName,
+ boolean stream,
+ Boolean enableThinking,
+ Boolean enableSearch,
+ GenerateOptions defaultOptions,
+ String baseUrl,
+ Formatter The model name determines which API is used:
- * The model name determines which API is used when endpointType is AUTO.
*
* @param modelName the model name (e.g., "qwen-max", "qwen-vl-plus")
* @return this builder instance
+ * @see DashScopeHttpClient#isMultimodalModel(String)
*/
public Builder modelName(String modelName) {
this.modelName = modelName;
@@ -374,6 +425,19 @@ public Builder enableSearch(Boolean enableSearch) {
return this;
}
+ /**
+ * Sets the endpoint type to use for endpoint routing.
+ *
+ * @param endpointType the endpoint type to use (null for AUTO)
+ * @return this builder instance
+ * @see EndpointType
+ * @see DashScopeHttpClient#isMultimodalModel(String)
+ */
+ public Builder endpointType(EndpointType endpointType) {
+ this.endpointType = endpointType;
+ return this;
+ }
+
/**
* Sets the default generation options.
*
@@ -503,6 +567,7 @@ public DashScopeChatModel build() {
stream,
enableThinking,
enableSearch,
+ endpointType,
effectiveOptions,
baseUrl,
formatter,
diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeHttpClient.java b/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeHttpClient.java
index 5c2689fb1..678d0ee9a 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeHttpClient.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/model/DashScopeHttpClient.java
@@ -165,7 +165,9 @@ public DashScopeResponse call(
Map Routing logic (consistent with existing SDK behavior):
+ * This is a convenience method that uses {@link EndpointType#AUTO} for automatic detection.
+ *
+ * @param modelName the model name
+ * @return the API endpoint path
+ */
+ public String selectEndpoint(String modelName) {
+ return selectEndpoint(modelName, EndpointType.AUTO);
+ }
+
+ /**
+ * Select the appropriate API endpoint based on model name and endpoint type.
+ *
+ * Routing logic:
* Supported multimodal model patterns (used when endpointType is AUTO):
+ * This is a convenience method that uses {@link EndpointType#AUTO} for automatic detection.
+ *
* @param modelName the model name
* @return true if the model requires multimodal API
*/
public boolean requiresMultimodalApi(String modelName) {
- return MULTIMODAL_GENERATION_ENDPOINT.equals(selectEndpoint(modelName));
+ return requiresMultimodalApi(modelName, EndpointType.AUTO);
+ }
+
+ /**
+ * Check if a model requires the multimodal API based on the specified endpoint type.
+ *
+ * @param modelName the model name
+ * @param endpointType the endpoint type to use
+ * @return true if the model requires multimodal API
+ */
+ public boolean requiresMultimodalApi(String modelName, EndpointType endpointType) {
+ if (endpointType == EndpointType.MULTIMODAL) {
+ return true;
+ }
+ if (endpointType == EndpointType.TEXT) {
+ return false;
+ }
+ // AUTO: use model name detection
+ return isMultimodalModel(modelName);
}
/**
diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/EndpointType.java b/agentscope-core/src/main/java/io/agentscope/core/model/EndpointType.java
new file mode 100644
index 000000000..5e1f51de9
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/model/EndpointType.java
@@ -0,0 +1,52 @@
+/*
+ * 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.model;
+
+/**
+ * Enum representing the endpoint type for DashScope models.
+ *
+ * This allows developers to explicitly specify which API endpoint to use,
+ * overriding the automatic model name-based detection.
+ *
+ * Usage example:
+ * This is the default behavior.
+ *
+ * @see DashScopeHttpClient#isMultimodalModel(String)
+ */
+ AUTO,
+
+ /**
+ * Force use of text generation API.
+ */
+ TEXT,
+
+ /**
+ * Force use of multimodal generation API.
+ */
+ MULTIMODAL
+}
diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeChatModelTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeChatModelTest.java
index c38f837b4..42195de0a 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeChatModelTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeChatModelTest.java
@@ -155,6 +155,104 @@ void testThinkingModeWithBudget() {
thinkingWithBudgetModel, "Model with thinking mode and budget should be created");
}
+ // ========== EndpointType Builder Tests ==========
+
+ @Test
+ @DisplayName("Should create model with explicit EndpointType.MULTIMODAL")
+ void testBuilderWithEndpointTypeMultimodal() {
+ DashScopeChatModel model =
+ DashScopeChatModel.builder()
+ .apiKey(mockApiKey)
+ .modelName("qwen3.5-plus")
+ .endpointType(EndpointType.MULTIMODAL)
+ .build();
+
+ assertNotNull(model, "Model with MULTIMODAL endpoint type should be created");
+ }
+
+ @Test
+ @DisplayName("Should create model with explicit EndpointType.TEXT")
+ void testBuilderWithEndpointTypeText() {
+ DashScopeChatModel model =
+ DashScopeChatModel.builder()
+ .apiKey(mockApiKey)
+ .modelName("qwen-plus")
+ .endpointType(EndpointType.TEXT)
+ .build();
+
+ assertNotNull(model, "Model with TEXT endpoint type should be created");
+ }
+
+ @Test
+ @DisplayName("Should create model with EndpointType.AUTO (default behavior)")
+ void testBuilderWithEndpointTypeAuto() {
+ DashScopeChatModel model =
+ DashScopeChatModel.builder()
+ .apiKey(mockApiKey)
+ .modelName("qwen-plus")
+ .endpointType(EndpointType.AUTO)
+ .build();
+
+ assertNotNull(model, "Model with AUTO endpoint type should be created");
+ }
+
+ @Test
+ @DisplayName("Should create model without endpointType (defaults to AUTO)")
+ void testBuilderWithoutEndpointType() {
+ // This tests backward compatibility - not setting endpointType should still work
+ DashScopeChatModel model =
+ DashScopeChatModel.builder().apiKey(mockApiKey).modelName("qwen-plus").stream(true)
+ .build();
+
+ assertNotNull(model, "Model without explicit endpointType should be created");
+ }
+
+ // ========== Backward Compatible Constructor Tests ==========
+
+ @Test
+ @DisplayName("Should create model using overloaded constructor without endpointType")
+ void testOverloadedConstructorWithoutEndpointType() {
+ // The overloaded constructor without endpointType should delegate to the full constructor
+ DashScopeChatModel model =
+ new DashScopeChatModel(
+ mockApiKey,
+ "qwen-plus",
+ true,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null);
+
+ assertNotNull(model, "Model from overloaded constructor should be created");
+ assertEquals("qwen-plus", model.getModelName());
+ }
+
+ @Test
+ @DisplayName("Should create model using full constructor with explicit endpointType")
+ void testFullConstructorWithEndpointType() {
+ DashScopeChatModel model =
+ new DashScopeChatModel(
+ mockApiKey,
+ "qwen3.5-plus",
+ true,
+ null,
+ null,
+ EndpointType.MULTIMODAL,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null);
+
+ assertNotNull(model, "Model from full constructor should be created");
+ assertEquals("qwen3.5-plus", model.getModelName());
+ }
+
// ========== Vision Model Tests ==========
@Test
diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeHttpClientTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeHttpClientTest.java
index d91cce636..1b3be8dad 100644
--- a/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeHttpClientTest.java
+++ b/agentscope-core/src/test/java/io/agentscope/core/model/DashScopeHttpClientTest.java
@@ -120,6 +120,148 @@ void testSelectEndpointForNullModel() {
assertEquals(DashScopeHttpClient.TEXT_GENERATION_ENDPOINT, client.selectEndpoint(null));
}
+ // ========== EndpointType Routing Tests ==========
+
+ @Test
+ void testSelectEndpointWithExplicitText() {
+ // Explicit TEXT forces text endpoint regardless of model name
+ assertEquals(
+ DashScopeHttpClient.TEXT_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen-vl-plus", EndpointType.TEXT));
+ assertEquals(
+ DashScopeHttpClient.TEXT_GENERATION_ENDPOINT,
+ client.selectEndpoint("qvq-72b", EndpointType.TEXT));
+ assertEquals(
+ DashScopeHttpClient.TEXT_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen3.5-plus", EndpointType.TEXT));
+ }
+
+ @Test
+ void testSelectEndpointWithExplicitMultimodal() {
+ // Explicit MULTIMODAL forces multimodal endpoint regardless of model name
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen-plus", EndpointType.MULTIMODAL));
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen-max", EndpointType.MULTIMODAL));
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen3.5-plus", EndpointType.MULTIMODAL));
+ }
+
+ @Test
+ void testSelectEndpointWithAutoFallsBackToModelNameDetection() {
+ // AUTO uses model name detection
+ assertEquals(
+ DashScopeHttpClient.TEXT_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen-plus", EndpointType.AUTO));
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen-vl-plus", EndpointType.AUTO));
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qvq-72b", EndpointType.AUTO));
+ assertEquals(
+ DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT,
+ client.selectEndpoint("qwen3.5-plus", EndpointType.AUTO));
+ }
+
+ @Test
+ void testRequiresMultimodalApiWithEndpointType() {
+ // Explicit MULTIMODAL always returns true
+ assertTrue(client.requiresMultimodalApi("qwen-plus", EndpointType.MULTIMODAL));
+ // Explicit TEXT always returns false
+ assertFalse(client.requiresMultimodalApi("qwen-vl-plus", EndpointType.TEXT));
+ // AUTO falls back to model name detection
+ assertFalse(client.requiresMultimodalApi("qwen-plus", EndpointType.AUTO));
+ assertTrue(client.requiresMultimodalApi("qwen-vl-plus", EndpointType.AUTO));
+ assertTrue(client.requiresMultimodalApi("qwen3.5-plus", EndpointType.AUTO));
+ }
+
+ @Test
+ void testIsMultimodalModelIncludesQwen35Family() {
+ // Qwen 3.5 family is entirely multimodal (prefix-based matching)
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3.5-plus"));
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3.5-plus-2026-02-15"));
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3.5-flash"));
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3.5-397b-a17b"));
+ // qwen-3.5-plus (with hyphen before 3.5) does not match
+ assertFalse(DashScopeHttpClient.isMultimodalModel("qwen-3.5-plus"));
+ }
+
+ @Test
+ void testIsMultimodalModelPatterns() {
+ // qvq prefix
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qvq-72b"));
+ assertTrue(DashScopeHttpClient.isMultimodalModel("QVQ-MAX"));
+ // -vl pattern
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen-vl-plus"));
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3-vl-max"));
+ // -asr pattern
+ assertTrue(DashScopeHttpClient.isMultimodalModel("qwen3-asr-flash"));
+ // Not multimodal
+ assertFalse(DashScopeHttpClient.isMultimodalModel("qwen-plus"));
+ assertFalse(DashScopeHttpClient.isMultimodalModel("qwen-max"));
+ assertFalse(DashScopeHttpClient.isMultimodalModel(null));
+ assertFalse(DashScopeHttpClient.isMultimodalModel(""));
+ }
+
+ @Test
+ void testRequestEndpointTypePassedToEndpointSelection() throws Exception {
+ // Verify that endpointType in DashScopeRequest is used for endpoint routing
+ String responseJson =
+ """
+ {
+ "request_id": "test",
+ "output": {
+ "choices": [{
+ "message": { "role": "assistant", "content": "ok" },
+ "finish_reason": "stop"
+ }]
+ }
+ }
+ """;
+ mockServer.enqueue(
+ new MockResponse()
+ .setResponseCode(200)
+ .setBody(responseJson)
+ .setHeader("Content-Type", "application/json"));
+
+ DashScopeMessage userMsg = DashScopeMessage.builder().role("user").content("Hello").build();
+ DashScopeRequest request =
+ DashScopeRequest.builder()
+ .model("qwen-plus")
+ .input(new DashScopeInput(List.of(userMsg)))
+ .parameters(new DashScopeParameters())
+ .endpointType(EndpointType.MULTIMODAL)
+ .build();
+
+ assertEquals(EndpointType.MULTIMODAL, request.getEndpointType());
+
+ DashScopeResponse response = client.call(request, null, null, null);
+ assertNotNull(response);
+
+ // Verify the request was sent to the multimodal endpoint
+ var recorded = mockServer.takeRequest();
+ assertTrue(
+ recorded.getPath().contains(DashScopeHttpClient.MULTIMODAL_GENERATION_ENDPOINT),
+ "Request should be sent to multimodal endpoint, but was: " + recorded.getPath());
+ }
+
+ @Test
+ void testRequestEndpointTypeDefaultsToAuto() {
+ DashScopeRequest request = new DashScopeRequest();
+ assertEquals(EndpointType.AUTO, request.getEndpointType());
+
+ DashScopeRequest request2 =
+ new DashScopeRequest("qwen-plus", new DashScopeInput(List.of()), null);
+ assertEquals(EndpointType.AUTO, request2.getEndpointType());
+
+ DashScopeRequest request3 = DashScopeRequest.builder().model("qwen-plus").build();
+ assertEquals(EndpointType.AUTO, request3.getEndpointType());
+ }
+
@Test
void testCallTextGenerationApi() throws Exception {
String responseJson =
- *
+ *
- *
*
* @param modelName the model name
+ * @param endpointType the endpoint type to use (AUTO for automatic detection)
* @return the API endpoint path
*/
- public String selectEndpoint(String modelName) {
+ public String selectEndpoint(String modelName, EndpointType endpointType) {
+ if (endpointType == EndpointType.TEXT) {
+ log.debug("Using text generation API (explicitly set) for model: {}", modelName);
+ return TEXT_GENERATION_ENDPOINT;
+ }
+ if (endpointType == EndpointType.MULTIMODAL) {
+ log.debug("Using multimodal API (explicitly set) for model: {}", modelName);
+ return MULTIMODAL_GENERATION_ENDPOINT;
+ }
+ // AUTO: use model name detection
if (modelName == null) {
return TEXT_GENERATION_ENDPOINT;
}
- if (modelName.startsWith("qvq") || modelName.contains("-vl")) {
- log.debug("Using multimodal API for model: {}", modelName);
+ if (isMultimodalModel(modelName)) {
+ log.debug("Using multimodal API (auto-detected) for model: {}", modelName);
return MULTIMODAL_GENERATION_ENDPOINT;
}
- log.debug("Using text generation API for model: {}", modelName);
+ log.debug("Using text generation API (auto-detected) for model: {}", modelName);
return TEXT_GENERATION_ENDPOINT;
}
+ /**
+ * Check if a model is a multimodal model that requires the multimodal-generation API.
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param modelName the model name
+ * @return true if the model is a multimodal model
+ */
+ public static boolean isMultimodalModel(String modelName) {
+ if (modelName == null) {
+ return false;
+ }
+ String lowerModelName = modelName.toLowerCase();
+ return lowerModelName.startsWith("qvq")
+ || lowerModelName.contains("-vl")
+ || lowerModelName.contains("-asr")
+ || lowerModelName.startsWith("qwen3.5");
+ }
+
/**
* Check if a model requires the multimodal API.
*
+ * {@code
+ * DashScopeChatModel model = DashScopeChatModel.builder()
+ * .apiKey("sk-xxx")
+ * .modelName("qwen3.5-plus")
+ * .endpointType(EndpointType.MULTIMODAL) // Explicitly use multimodal API
+ * .build();
+ * }
+ */
+public enum EndpointType {
+ /**
+ * Automatically determine endpoint type based on model name.
+ *
+ *