From 8eeaff66347f7d762a09a9e1e1ff6bff87b47b77 Mon Sep 17 00:00:00 2001 From: zouyx Date: Wed, 1 Jul 2026 22:45:28 +0800 Subject: [PATCH 1/4] fix(shell): escape spaces in skill paths returned by ShellPathPolicy --- .../agent/skill/runtime/ShellPathPolicy.java | 45 ++++++++----- .../agent/skill/runtime/SkillRuntimeTest.java | 66 +++++++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/ShellPathPolicy.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/ShellPathPolicy.java index a6a5b7da7..852bfc8bd 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/ShellPathPolicy.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/skill/runtime/ShellPathPolicy.java @@ -112,9 +112,14 @@ public String resolve(String skillName, StageResult stage) { private String joinSkills(String skillName) { return switch (mode) { - case SANDBOX -> sandboxPrefix + "/skills/" + skillName; + case SANDBOX -> escapeSpaces(sandboxPrefix + "/skills/" + skillName); case LOCAL_WITH_SHELL -> - workspaceRoot.resolve("skills").resolve(skillName).toAbsolutePath().toString(); + escapeSpaces( + workspaceRoot + .resolve("skills") + .resolve(skillName) + .toAbsolutePath() + .toString()); case NO_SHELL -> null; }; } @@ -122,21 +127,31 @@ private String joinSkills(String skillName) { private String joinCache(String sourceNs, String skillName) { return switch (mode) { case SANDBOX -> - sandboxPrefix - + "/" - + MarketplaceStager.CACHE_DIR - + "/" - + sourceNs - + "/" - + skillName; + escapeSpaces( + sandboxPrefix + + "/" + + MarketplaceStager.CACHE_DIR + + "/" + + sourceNs + + "/" + + skillName); case LOCAL_WITH_SHELL -> - workspaceRoot - .resolve(MarketplaceStager.CACHE_DIR) - .resolve(sourceNs) - .resolve(skillName) - .toAbsolutePath() - .toString(); + escapeSpaces( + workspaceRoot + .resolve(MarketplaceStager.CACHE_DIR) + .resolve(sourceNs) + .resolve(skillName) + .toAbsolutePath() + .toString()); case NO_SHELL -> null; }; } + + /** + * Escapes space characters with backslash so the path can be safely used in shell commands + * by the LLM. + */ + static String escapeSpaces(String value) { + return value.indexOf(' ') >= 0 ? value.replace(" ", "\\ ") : value; + } } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java index 68305a483..575fb6012 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/skill/runtime/SkillRuntimeTest.java @@ -30,6 +30,8 @@ import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.Toolkit; import io.agentscope.harness.agent.skill.SkillResources; +import io.agentscope.harness.agent.skill.runtime.MarketplaceStager.StageResult; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,6 +76,59 @@ void laterEntryWithSameIdOverwrites() { } } + // ========================================================================= + // ShellPathPolicy + // ========================================================================= + + @Nested + class ShellPathPolicyTests { + + @Test + void escapeSpacesReplacesWithBackslashSpace() { + assertEquals("hello", ShellPathPolicy.escapeSpaces("hello")); + assertEquals("hello\\ world", ShellPathPolicy.escapeSpaces("hello world")); + assertEquals("V2Ray\\ 代理配置助手", ShellPathPolicy.escapeSpaces("V2Ray 代理配置助手")); + assertEquals("a\\ b\\ c", ShellPathPolicy.escapeSpaces("a b c")); + } + + @Test + void sandboxResolveEscapesSpacesInSkillName() { + ShellPathPolicy policy = ShellPathPolicy.sandbox(); + String result = policy.resolve("V2Ray 代理配置助手", new StageResult.WorkspaceNative()); + assertEquals("/workspace/skills/V2Ray\\ 代理配置助手", result); + } + + @Test + void sandboxResolveEscapesSpacesInCachedSkill() { + ShellPathPolicy policy = ShellPathPolicy.sandbox(); + String result = policy.resolve("ignored", new StageResult.Cached("ns", "my skill")); + assertEquals("/workspace/.skills-cache/ns/my\\ skill", result); + } + + @Test + void localWithShellResolveEscapesSpaces() { + ShellPathPolicy policy = ShellPathPolicy.localWithShell(Paths.get("/tmp/my workspace")); + String result = policy.resolve("my skill", new StageResult.WorkspaceNative()); + assertEquals("/tmp/my\\ workspace/skills/my\\ skill", result); + } + + @Test + void noShellAlwaysReturnsNull() { + ShellPathPolicy policy = ShellPathPolicy.noShell(); + assertNull(policy.resolve("any name", new StageResult.WorkspaceNative())); + assertNull(policy.resolve("any name", new StageResult.Cached("ns", "name"))); + assertNull(policy.resolve("any name", StageResult.NONE)); + assertNull(policy.resolve("any name", null)); + } + + @Test + void nullStageOrNoneReturnsNull() { + ShellPathPolicy policy = ShellPathPolicy.sandbox(); + assertNull(policy.resolve("alpha", null)); + assertNull(policy.resolve("alpha", StageResult.NONE)); + } + } + // ========================================================================= // SkillPromptBuilder // ========================================================================= @@ -109,6 +164,17 @@ void rendersFilesRootAndCodeExecutionWhenAvailable() { assertTrue(out.contains("")); } + @Test + void rendersEscapedFilesRootWhenPathContainsSpaces() { + HarnessSkillEntry e = + new HarnessSkillEntry( + skill("V2Ray 代理配置助手", "wkspace"), + null, + "/workspace/skills/V2Ray\\ 代理配置助手"); + String out = new SkillPromptBuilder().render(SkillCatalog.of(List.of(e))); + assertTrue(out.contains("/workspace/skills/V2Ray\\ 代理配置助手")); + } + @Test void filterRemovesHiddenSkills() { HarnessSkillEntry visible = HarnessSkillEntry.of(skill("visible", "src"), null); From 469456198f744c1f7b421fcf9c497c21cd34cbd1 Mon Sep 17 00:00:00 2001 From: Jiang Shan <2087687391@qq.com> Date: Wed, 1 Jul 2026 20:22:29 +0800 Subject: [PATCH 2/4] feat(model): finalize the model migration (#1972) --- README.md | 2 + README_zh.md | 2 + SKILL.md | 97 +++--- .../java/io/agentscope/core/ReActAgent.java | 2 + .../core/credential/DeepSeekCredential.java | 3 +- .../core/credential/KimiCredential.java | 3 +- .../core/credential/XAICredential.java | 3 +- .../io/agentscope/core/model/CachePolicy.java | 45 +++ .../core/model/ModelCreationContext.java | 290 +++++++++++++++++ .../agentscope/core/model/ModelRegistry.java | 155 ++++++++- .../core/model/spi/ModelProvider.java | 16 +- .../core/model/ModelRegistryTest.java | 157 +++++++++ .../core/model/ModelTimeoutRetryTest.java | 2 +- .../agentscope-bom/pom.xml | 28 ++ .../anthropic/AnthropicChatModelFactory.java | 44 --- .../anthropic/AnthropicModelProvider.java | 68 +++- .../anthropic/AnthropicModelProviderTest.java | 25 ++ .../dashscope/DashScopeChatModelFactory.java | 54 --- .../dashscope/DashScopeModelProvider.java | 143 +++++++- .../dashscope/DashScopeModelProviderTest.java | 29 ++ .../model/gemini/GeminiChatModelFactory.java | 63 ---- .../model/gemini/GeminiModelProvider.java | 163 ++++++++- .../model/gemini/GeminiChatModelTest.java | 14 - .../model/gemini/GeminiModelProviderTest.java | 47 +++ .../model/ollama/OllamaChatModelFactory.java | 39 --- .../model/ollama/OllamaModelProvider.java | 85 ++++- .../model/ollama/OllamaModelProviderTest.java | 23 ++ .../model/openai/OpenAIChatModelFactory.java | 50 --- .../model/openai/OpenAIModelProvider.java | 113 ++++++- .../model/openai/OpenAIModelProviderTest.java | 27 ++ .../pom.xml | 71 ++++ .../anthropic/AnthropicAutoConfiguration.java | 75 +++++ .../boot/anthropic}/AnthropicProperties.java | 7 +- ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../AnthropicAutoConfigurationTest.java | 173 ++++++++++ .../pom.xml | 71 ++++ .../ConditionalOnDashScopeProvider.java | 35 ++ .../dashscope/DashScopeAutoConfiguration.java | 82 +++++ .../boot/dashscope/DashScopeProperties.java} | 9 +- .../dashscope/DashScopeProviderCondition.java | 32 ++ ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../DashScopeAutoConfigurationTest.java | 186 +++++++++++ .../pom.xml | 71 ++++ .../boot/gemini/GeminiAutoConfiguration.java | 93 ++++++ .../spring/boot/gemini}/GeminiProperties.java | 7 +- ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../gemini/GeminiAutoConfigurationTest.java | 204 ++++++++++++ .../pom.xml | 71 ++++ .../boot/openai/OpenAIAutoConfiguration.java | 83 +++++ .../spring/boot/openai}/OpenAIProperties.java | 5 +- ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../openai/OpenAIAutoConfigurationTest.java | 170 ++++++++++ .../agentscope-spring-boot-starter/pom.xml | 25 -- .../boot/AgentscopeAutoConfiguration.java | 105 +----- .../spring/boot/model/ModelProviderType.java | 308 ------------------ .../boot/properties/AgentscopeProperties.java | 28 -- .../boot/AgentscopeAutoConfigurationTest.java | 260 ++++----------- .../agentscope-spring-boot-starters/pom.xml | 4 + docs/v1/en/docs/multi-agent/handoffs.md | 2 +- .../en/docs/multi-agent/multiagent-debate.md | 8 +- docs/v1/en/docs/multi-agent/pipeline.md | 2 +- docs/v1/en/docs/quickstart/agent.md | 2 +- docs/v1/en/docs/task/agent-as-tool.md | 2 +- docs/v1/en/docs/task/agent-config.md | 4 +- docs/v1/en/docs/task/msghub.md | 4 +- docs/v1/en/docs/task/multimodal.md | 8 +- docs/v1/en/docs/task/observability.md | 2 +- docs/v1/zh/docs/multi-agent/handoffs.md | 2 +- .../zh/docs/multi-agent/multiagent-debate.md | 8 +- docs/v1/zh/docs/multi-agent/pipeline.md | 2 +- docs/v1/zh/docs/quickstart/agent.md | 2 +- docs/v1/zh/docs/task/agent-as-tool.md | 2 +- docs/v1/zh/docs/task/agent-config.md | 4 +- docs/v1/zh/docs/task/msghub.md | 4 +- docs/v1/zh/docs/task/multimodal.md | 8 +- docs/v1/zh/docs/task/observability.md | 2 +- docs/v2/en/docs/building-blocks/agent.md | 4 +- docs/v2/en/docs/building-blocks/model.md | 103 +++++- docs/v2/zh/docs/building-blocks/agent.md | 4 +- docs/v2/zh/docs/building-blocks/model.md | 103 +++++- 80 files changed, 3239 insertions(+), 1074 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/model/CachePolicy.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/model/ModelCreationContext.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelFactory.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModelFactory.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiChatModelFactory.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModelFactory.java delete mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModelFactory.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/pom.xml create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java rename agentscope-extensions/agentscope-spring-boot-starters/{agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties => agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic}/AnthropicProperties.java (89%) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/pom.xml create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/ConditionalOnDashScopeProvider.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfiguration.java rename agentscope-extensions/agentscope-spring-boot-starters/{agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/DashscopeProperties.java => agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProperties.java} (91%) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProviderCondition.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfigurationTest.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/pom.xml create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiAutoConfiguration.java rename agentscope-extensions/agentscope-spring-boot-starters/{agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties => agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini}/GeminiProperties.java (93%) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/test/java/io/agentscope/spring/boot/gemini/GeminiAutoConfigurationTest.java create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/pom.xml create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIAutoConfiguration.java rename agentscope-extensions/agentscope-spring-boot-starters/{agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties => agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai}/OpenAIProperties.java (94%) create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/test/java/io/agentscope/spring/boot/openai/OpenAIAutoConfigurationTest.java delete mode 100644 agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/model/ModelProviderType.java diff --git a/README.md b/README.md index 508ac05b8..513f71c62 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ Built for enterprise deployment requirements: ``` ```java +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; + ReActAgent agent = ReActAgent.builder() .name("Assistant") .sysPrompt("You are a helpful AI assistant.") diff --git a/README_zh.md b/README_zh.md index 96d1f9108..3fc6cffea 100644 --- a/README_zh.md +++ b/README_zh.md @@ -82,6 +82,8 @@ AgentScope 设计上能够与现有企业基础设施集成,无需大规模改 ``` ```java +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; + ReActAgent agent = ReActAgent.builder() .name("Assistant") .sysPrompt("You are a helpful AI assistant.") diff --git a/SKILL.md b/SKILL.md index fe78f8055..eb121b26f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -21,7 +21,7 @@ When the user asks you to write AgentScope Java code, follow these instructions 3. **NEVER use `ThreadLocal`** - Use Reactor Context with `Mono.deferContextual()`. 4. **NEVER hardcode API keys** - Always use `System.getenv()`. 5. **NEVER ignore errors silently** - Always log errors and provide fallback values. -6. **NEVER use wrong import paths** - All models are in `io.agentscope.core.model.*`, NOT `io.agentscope.model.*`. +6. **NEVER use wrong import paths** - Shared model interfaces are in `io.agentscope.core.model.*`; provider models are in `io.agentscope.extensions.model..*`. **✅ ALWAYS DO:** 1. **Use `Mono` and `Flux`** for all asynchronous operations. @@ -29,7 +29,7 @@ When the user asks you to write AgentScope Java code, follow these instructions 3. **Use Builder pattern** for creating agents, models, and messages. 4. **Include error handling** with `.onErrorResume()` or `.onErrorReturn()`. 5. **Add logging** with SLF4J for important operations. -6. **Use correct imports**: `import io.agentscope.core.model.DashScopeChatModel;` +6. **Use correct imports**: `import io.agentscope.extensions.model.dashscope.DashScopeChatModel;` 7. **Use correct APIs** (many methods don't exist or have changed): - `toolkit.registerTool()` NOT `registerObject()` - `toolkit.getToolNames()` NOT `getTools()` @@ -56,7 +56,7 @@ When the user asks you to write AgentScope Java code, follow these instructions 2. Check: Are all operations non-blocking? → If no, **FIX IT**. 3. Check: Does it have error handling? → If no, **ADD IT**. 4. Check: Are API keys from environment? → If no, **CHANGE IT**. -5. Check: Are imports correct? → If using `io.agentscope.model.*`, **FIX TO** `io.agentscope.core.model.*`. +5. Check: Are imports correct? → If using provider models from `io.agentscope.core.model.*`, **FIX TO** `io.agentscope.extensions.model..*`. **Default code structure for agent logic:** ```java @@ -118,6 +118,11 @@ public static void main(String[] args) { agentscope-core ${agentscope.version} + + io.agentscope + agentscope-extensions-model-dashscope + ${agentscope.version} + ``` @@ -165,23 +170,23 @@ public static void main(String[] args) { ``` -### Available Model Classes (all in agentscope-core) +### Available Model Classes (provider-specific extension modules) ```java // DashScope (Alibaba Cloud) -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; // OpenAI import io.agentscope.extensions.model.openai.OpenAIChatModel; // Gemini (Google) -import io.agentscope.core.model.GeminiChatModel; +import io.agentscope.extensions.model.gemini.GeminiChatModel; // Anthropic (Claude) -import io.agentscope.core.model.AnthropicChatModel; +import io.agentscope.extensions.model.anthropic.AnthropicChatModel; // Ollama (Local models) -import io.agentscope.core.model.OllamaChatModel; +import io.agentscope.extensions.model.ollama.OllamaChatModel; ``` ### Optional Extensions @@ -320,7 +325,7 @@ Extend `AgentBase` and implement `doCall(List msgs)`: public class MyAgent extends AgentBase { private final Model model; private final Memory memory; - + public MyAgent(String name, Model model) { super(name, "A custom agent", true, List.of()); this.model = model; @@ -333,7 +338,7 @@ public class MyAgent extends AgentBase { if (msgs != null) { msgs.forEach(memory::addMessage); } - + // 2. Call model or logic return model.generate(memory.getMessages(), null, null) .map(response -> Msg.builder() @@ -362,7 +367,7 @@ Use `@Tool` annotation for function-based tools. Tools can return: public class WeatherTools { @Tool(description = "Get current weather for a city. Returns temperature and conditions.") public String getWeather( - @ToolParam(name = "city", description = "City name, e.g., 'San Francisco'") + @ToolParam(name = "city", description = "City name, e.g., 'San Francisco'") String city) { // Implementation return "Sunny, 25°C"; @@ -374,10 +379,10 @@ public class WeatherTools { ```java public class AsyncTools { private final WebClient webClient; - + @Tool(description = "Fetch data from trusted API endpoint") public Mono fetchData( - @ToolParam(name = "url", description = "API endpoint URL (must start with https://api.myservice.com)") + @ToolParam(name = "url", description = "API endpoint URL (must start with https://api.myservice.com)") String url) { // SECURITY: Validate URL to prevent SSRF if (!url.startsWith("https://api.myservice.com")) { @@ -444,7 +449,7 @@ Hook loggingHook = new Hook() { return Mono.just(event); } } - + @Override public int priority() { return 500; // Low priority (logging) @@ -475,7 +480,7 @@ Hook loggingHook = new Hook() { } return Mono.just(event); } - + @Override public int priority() { return 500; @@ -597,7 +602,7 @@ void testAgentCall() { .role(MsgRole.USER) .content(TextBlock.builder().text("Hello").build()) .build(); - + StepVerifier.create(agent.call(input)) .assertNext(response -> { assertEquals(MsgRole.ASSISTANT, response.getRole()); @@ -616,12 +621,12 @@ void testWithMockModel() { .thenReturn(Mono.just(ChatResponse.builder() .text("Mocked response") .build())); - + ReActAgent agent = ReActAgent.builder() .name("TestAgent") .model(mockModel) .build(); - + // Test agent behavior } ``` @@ -722,7 +727,7 @@ Duration delay = Duration.ofMillis((long) Math.pow(2, attempt) * baseDelayMs); ```java // ❌ WRONG Thread.sleep(1000); - + // ✅ CORRECT return Mono.delay(Duration.ofSeconds(1)); ``` @@ -740,7 +745,7 @@ Duration delay = Duration.ofMillis((long) Math.pow(2, attempt) * baseDelayMs); ```java // ❌ WRONG .onErrorResume(e -> Mono.empty()) - + // ✅ CORRECT .onErrorResume(e -> { log.error("Operation failed", e); @@ -752,7 +757,7 @@ Duration delay = Duration.ofMillis((long) Math.pow(2, attempt) * baseDelayMs); ```java // ❌ WRONG ThreadLocal context = new ThreadLocal<>(); - + // ✅ CORRECT return Mono.deferContextual(ctx -> { String value = ctx.get("key"); @@ -775,7 +780,7 @@ Duration delay = Duration.ofMillis((long) Math.pow(2, attempt) * baseDelayMs); ```java // ❌ WRONG String apiKey = "sk-1234567890"; - + // ✅ CORRECT String apiKey = System.getenv("OPENAI_API_KEY"); ``` @@ -788,7 +793,7 @@ Duration delay = Duration.ofMillis((long) Math.pow(2, attempt) * baseDelayMs); case PostActingEvent e -> handleActing(e); default -> Mono.just(event); }; - + // ✅ CORRECT - Java 17 compatible if (event instanceof PreReasoningEvent e) { return handleReasoning(e); @@ -878,7 +883,7 @@ import io.agentscope.core.model.Model; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; import io.agentscope.core.tool.Toolkit; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -890,9 +895,9 @@ import java.time.format.DateTimeFormatter; * Complete example demonstrating AgentScope best practices. */ public class CompleteExample { - + private static final Logger log = LoggerFactory.getLogger(CompleteExample.class); - + public static void main(String[] args) { // 1. Create model (no .temperature() method, use defaultOptions) Model model = DashScopeChatModel.builder() @@ -900,12 +905,12 @@ public class CompleteExample { .modelName("qwen-plus") .stream(true) .build(); - + // 2. Create toolkit with tools Toolkit toolkit = new Toolkit(); toolkit.registerTool(new WeatherTools()); toolkit.registerTool(new TimeTools()); - + // 3. Create hook for streaming output Hook streamingHook = new Hook() { @Override @@ -918,13 +923,13 @@ public class CompleteExample { } return Mono.just(event); } - + @Override public int priority() { return 500; // Low priority } }; - + // 4. Build agent ReActAgent agent = ReActAgent.builder() .name("Assistant") @@ -935,7 +940,7 @@ public class CompleteExample { .hook(streamingHook) .maxIters(10) .build(); - + // 5. Use agent Msg userMsg = Msg.builder() .role(MsgRole.USER) @@ -943,57 +948,57 @@ public class CompleteExample { .text("What's the weather in San Francisco and what time is it?") .build()) .build(); - + try { System.out.println("User: " + userMsg.getTextContent()); System.out.print("Assistant: "); - + // ⚠️ IMPORTANT: .block() is ONLY allowed in main() methods for demo purposes // NEVER use .block() in agent logic, service methods, or library code Msg response = agent.call(userMsg).block(); - + System.out.println("\n\n--- Response Details ---"); System.out.println("Role: " + response.getRole()); System.out.println("Content: " + response.getTextContent()); - + } catch (Exception e) { log.error("Error during agent execution", e); System.err.println("Error: " + e.getMessage()); } } - + /** * Example tool class for weather information. */ public static class WeatherTools { - + @Tool(description = "Get current weather for a city. Returns temperature and conditions.") public String getWeather( - @ToolParam(name = "city", description = "City name, e.g., 'San Francisco'") + @ToolParam(name = "city", description = "City name, e.g., 'San Francisco'") String city) { - + log.info("Getting weather for city: {}", city); - + // Simulate API call return String.format("Weather in %s: Sunny, 22°C, Light breeze", city); } } - + /** * Example tool class for time information. */ public static class TimeTools { - - private static final DateTimeFormatter FORMATTER = + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - + @Tool(description = "Get current date and time") public String getCurrentTime() { LocalDateTime now = LocalDateTime.now(); String formatted = now.format(FORMATTER); - + log.info("Returning current time: {}", formatted); - + return "Current time: " + formatted; } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 4428d0b68..17199b312 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -163,6 +163,8 @@ * *

Usage Example: *

{@code
+ * import io.agentscope.extensions.model.dashscope.DashScopeChatModel;
+ *
  * // Create a model (requires dependency: agentscope-extensions-model-dashscope)
  * DashScopeChatModel model = DashScopeChatModel.builder()
  *     .apiKey(System.getenv("DASHSCOPE_API_KEY"))
diff --git a/agentscope-core/src/main/java/io/agentscope/core/credential/DeepSeekCredential.java b/agentscope-core/src/main/java/io/agentscope/core/credential/DeepSeekCredential.java
index 639a7a88b..8f8161a06 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/credential/DeepSeekCredential.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/credential/DeepSeekCredential.java
@@ -73,7 +73,8 @@ public String getBaseUrl() {
     public Class getChatModelClass() {
         throw new UnsupportedOperationException(
                 "DeepSeekChatModel is not implemented in agentscope-core yet."
-                        + " Use the OpenAIChatModel against the DeepSeek base URL instead.");
+                        + " Use io.agentscope.extensions.model.openai.OpenAIChatModel against the"
+                        + " DeepSeek base URL instead.");
     }
 
     @Override
diff --git a/agentscope-core/src/main/java/io/agentscope/core/credential/KimiCredential.java b/agentscope-core/src/main/java/io/agentscope/core/credential/KimiCredential.java
index 3b43e144e..3e5e26272 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/credential/KimiCredential.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/credential/KimiCredential.java
@@ -72,7 +72,8 @@ public String getBaseUrl() {
     public Class getChatModelClass() {
         throw new UnsupportedOperationException(
                 "KimiChatModel is not implemented in agentscope-core yet."
-                        + " Use the OpenAIChatModel against the Kimi base URL instead.");
+                        + " Use io.agentscope.extensions.model.openai.OpenAIChatModel against the"
+                        + " Kimi base URL instead.");
     }
 
     @Override
diff --git a/agentscope-core/src/main/java/io/agentscope/core/credential/XAICredential.java b/agentscope-core/src/main/java/io/agentscope/core/credential/XAICredential.java
index f8687bff2..8f99f8c0a 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/credential/XAICredential.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/credential/XAICredential.java
@@ -61,7 +61,8 @@ public String getApiKey() {
     public Class getChatModelClass() {
         throw new UnsupportedOperationException(
                 "XAIChatModel is not implemented in agentscope-core yet."
-                        + " Use the OpenAIChatModel against the xAI base URL instead.");
+                        + " Use io.agentscope.extensions.model.openai.OpenAIChatModel against the"
+                        + " xAI base URL instead.");
     }
 
     @Override
diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/CachePolicy.java b/agentscope-core/src/main/java/io/agentscope/core/model/CachePolicy.java
new file mode 100644
index 000000000..9addf5601
--- /dev/null
+++ b/agentscope-core/src/main/java/io/agentscope/core/model/CachePolicy.java
@@ -0,0 +1,45 @@
+/*
+ * 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;
+
+/**
+ * Controls whether {@link ModelRegistry} caches models created with a
+ * {@link ModelCreationContext}.
+ *
+ * 

Policy summary: + * + *

    + *
  • {@link #DEFAULT}: preserves legacy behavior. Empty contexts are cached by model id; + * non-empty contexts are not cached. + *
  • {@link #DISABLED}: never cache. Every resolution creates a new model instance. + *
  • {@link #ENABLED}: opt-in caching. The cache identity is either the explicit + * {@code cacheId} supplied by the caller or a fingerprint derived only from standard simple + * fields. Contexts containing opaque options/components require an explicit {@code cacheId}. + *
+ */ +public enum CachePolicy { + /** + * Preserve registry defaults. Legacy {@link ModelRegistry#resolve(String)} calls are cached, + * while non-empty context-based resolutions are not cached. + */ + DEFAULT, + + /** Never cache the created model. */ + DISABLED, + + /** Cache the created model using an explicit or safely derived context cache identity. */ + ENABLED +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelCreationContext.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCreationContext.java new file mode 100644 index 000000000..7425634c3 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCreationContext.java @@ -0,0 +1,290 @@ +/* + * 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; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Provider-neutral model creation context used by {@link ModelRegistry} and model provider SPI + * implementations. + * + *

This type has two layers: + * + *

    + *
  • Standard fields such as {@code apiKey}, {@code baseUrl}, {@code endpointPath}, + * {@code stream}, and {@code enableThinking}. These are common enough to be understood by + * multiple providers and can safely participate in automatic cache identity when + * {@link CachePolicy#ENABLED} is selected. + *
  • Provider-specific {@linkplain #option(String) options} and typed {@linkplain + * #component(Class) components}. These allow extension modules to consume advanced builder + * settings such as formatters, HTTP transports, credentials, proxies, and default generation + * options without adding provider types to core. + *
+ * + *

Options and components are intentionally opaque to core. {@link ModelRegistry} never deep + * hashes them or uses their {@code hashCode()} values for cache identity. When they are present and + * caching is enabled, callers must provide an explicit {@link Builder#cacheId(String)}. + */ +public final class ModelCreationContext { + + private static final ModelCreationContext EMPTY = builder().build(); + + private final String apiKey; + private final String baseUrl; + private final String endpointPath; + private final Boolean stream; + private final Boolean enableThinking; + private final CachePolicy cachePolicy; + private final String cacheId; + private final Map options; + private final Map, Object> components; + + private ModelCreationContext(Builder builder) { + this.apiKey = builder.apiKey; + this.baseUrl = builder.baseUrl; + this.endpointPath = builder.endpointPath; + this.stream = builder.stream; + this.enableThinking = builder.enableThinking; + this.cachePolicy = builder.cachePolicy; + this.cacheId = builder.cacheId; + this.options = Map.copyOf(builder.options); + this.components = Map.copyOf(builder.components); + } + + public static ModelCreationContext empty() { + return EMPTY; + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Returns a mutable builder initialized from this context. + * + *

This is useful when a caller wants to derive a tenant- or request-specific context from a + * shared baseline without mutating the original instance. + */ + public Builder toBuilder() { + return new Builder(this); + } + + /** API key or token to pass to a provider. Providers may fall back to environment variables. */ + public String getApiKey() { + return apiKey; + } + + /** Provider base URL override, for self-hosted or compatible API endpoints. */ + public String getBaseUrl() { + return baseUrl; + } + + /** Provider endpoint path override, mainly for OpenAI-compatible APIs. */ + public String getEndpointPath() { + return endpointPath; + } + + /** Whether the created model should use streaming by default. {@code null} means provider default. */ + public Boolean getStream() { + return stream; + } + + /** Whether provider thinking/reasoning mode should be enabled. */ + public Boolean getEnableThinking() { + return enableThinking; + } + + /** Cache policy for {@link ModelRegistry#resolve(String, ModelCreationContext)}. */ + public CachePolicy getCachePolicy() { + return cachePolicy; + } + + /** Explicit cache identity used when {@link #getCachePolicy()} is {@link CachePolicy#ENABLED}. */ + public String getCacheId() { + return cacheId; + } + + /** Provider-specific scalar or value options keyed by extension-defined names. */ + public Map getOptions() { + return options; + } + + /** Returns a provider-specific option value, or {@code null}. */ + public Object option(String key) { + return options.get(key); + } + + /** Returns a provider-specific option cast to the requested type, or {@code null}. */ + public T option(String key, Class type) { + Object value = options.get(key); + return value == null ? null : type.cast(value); + } + + /** Provider-specific component objects keyed by type. */ + public Map, Object> getComponents() { + return components; + } + + /** Returns a provider-specific component by type, or {@code null}. */ + public T component(Class type) { + Object value = components.get(type); + return value == null ? null : type.cast(value); + } + + /** + * Returns whether this context has no effective settings. + * + *

The empty context preserves legacy {@link ModelRegistry#resolve(String)} caching behavior. + */ + public boolean isEmpty() { + return apiKey == null + && baseUrl == null + && endpointPath == null + && stream == null + && enableThinking == null + && cachePolicy == CachePolicy.DEFAULT + && cacheId == null + && options.isEmpty() + && components.isEmpty(); + } + + /** Returns whether the context contains values that core cannot fingerprint safely. */ + public boolean hasOpaqueCacheInputs() { + return !options.isEmpty() || !components.isEmpty(); + } + + @Override + public String toString() { + return "ModelCreationContext{" + + "apiKey=" + + (apiKey == null ? "null" : "[REDACTED]") + + ", baseUrl='" + + baseUrl + + '\'' + + ", endpointPath='" + + endpointPath + + '\'' + + ", stream=" + + stream + + ", enableThinking=" + + enableThinking + + ", cachePolicy=" + + cachePolicy + + ", cacheId='" + + cacheId + + '\'' + + ", options=" + + options.keySet() + + ", components=" + + components.keySet() + + '}'; + } + + public static final class Builder { + private String apiKey; + private String baseUrl; + private String endpointPath; + private Boolean stream; + private Boolean enableThinking; + private CachePolicy cachePolicy = CachePolicy.DEFAULT; + private String cacheId; + private final Map options = new LinkedHashMap<>(); + private final Map, Object> components = new LinkedHashMap<>(); + + private Builder() {} + + private Builder(ModelCreationContext source) { + this.apiKey = source.apiKey; + this.baseUrl = source.baseUrl; + this.endpointPath = source.endpointPath; + this.stream = source.stream; + this.enableThinking = source.enableThinking; + this.cachePolicy = source.cachePolicy; + this.cacheId = source.cacheId; + this.options.putAll(source.options); + this.components.putAll(source.components); + } + + public Builder apiKey(String apiKey) { + this.apiKey = trimToNull(apiKey); + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = trimToNull(baseUrl); + return this; + } + + public Builder endpointPath(String endpointPath) { + this.endpointPath = trimToNull(endpointPath); + return this; + } + + public Builder stream(Boolean stream) { + this.stream = stream; + return this; + } + + public Builder enableThinking(Boolean enableThinking) { + this.enableThinking = enableThinking; + return this; + } + + public Builder cachePolicy(CachePolicy cachePolicy) { + this.cachePolicy = Objects.requireNonNull(cachePolicy, "cachePolicy"); + return this; + } + + public Builder cacheId(String cacheId) { + this.cacheId = trimToNull(cacheId); + return this; + } + + public Builder option(String key, Object value) { + Objects.requireNonNull(key, "key"); + if (value == null) { + options.remove(key); + } else { + options.put(key, value); + } + return this; + } + + public Builder component(Class type, T value) { + Objects.requireNonNull(type, "type"); + if (value == null) { + components.remove(type); + } else { + components.put(type, value); + } + return this; + } + + public ModelCreationContext build() { + return new ModelCreationContext(this); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java index 04134a0c3..dd971a6b4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java @@ -16,8 +16,12 @@ package io.agentscope.core.model; import io.agentscope.core.model.spi.ModelProvider; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; +import java.util.HexFormat; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -44,7 +48,8 @@ public final class ModelRegistry { private static final ConcurrentHashMap namedModels = new ConcurrentHashMap<>(); private static final CopyOnWriteArrayList userFactories = new CopyOnWriteArrayList<>(); - private static final ConcurrentHashMap resolvedCache = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap resolvedCache = + new ConcurrentHashMap<>(); private static volatile List serviceProviders; private ModelRegistry() {} @@ -69,6 +74,21 @@ public static void register(String name, Model model) { * @param factory creates a {@link Model} from the full model id */ public static void registerFactory(String modelNameRegex, ModelFactory factory) { + Objects.requireNonNull(modelNameRegex, "modelNameRegex"); + Objects.requireNonNull(factory, "factory"); + registerFactory(modelNameRegex, (modelId, context) -> factory.create(modelId)); + } + + /** + * Registers a context-aware factory matched against the full {@code modelId} string using + * {@link Pattern#matches}. Newly registered factories are consulted before older user + * registrations and before SPI providers. + * + * @param modelNameRegex regex with semantics of Pattern#matches(CharSequence) on the + * full model id + * @param factory creates a {@link Model} from the full model id and creation context + */ + public static void registerFactory(String modelNameRegex, ContextModelFactory factory) { Objects.requireNonNull(modelNameRegex, "modelNameRegex"); Objects.requireNonNull(factory, "factory"); Pattern pattern = Pattern.compile(modelNameRegex); @@ -82,7 +102,22 @@ public static void registerFactory(String modelNameRegex, ModelFactory factory) * @throws IllegalArgumentException if the id cannot be resolved or creation fails */ public static Model resolve(String modelId) { + return resolve(modelId, ModelCreationContext.empty()); + } + + /** + * Resolves a {@link Model} for the given id and creation context: named registration first, + * then cache when enabled, then user factories (newest first), then SPI providers. + * + *

Non-empty context resolution is not cached unless the context uses + * {@link CachePolicy#ENABLED}. + * + * @throws IllegalArgumentException if the id cannot be resolved, the context cannot be safely + * cached, or creation fails + */ + public static Model resolve(String modelId, ModelCreationContext context) { Objects.requireNonNull(modelId, "modelId"); + Objects.requireNonNull(context, "context"); String trimmed = modelId.trim(); if (trimmed.isEmpty()) { throw new IllegalArgumentException("modelId must not be blank"); @@ -93,13 +128,16 @@ public static Model resolve(String modelId) { return named; } - Model cached = resolvedCache.get(trimmed); - if (cached != null) { - return cached; + ModelCacheKey cacheKey = cacheKey(trimmed, context); + if (cacheKey != null) { + Model cached = resolvedCache.get(cacheKey); + if (cached != null) { + return cached; + } } ProviderEntry entry = findMatchingUserEntry(trimmed); - ModelProvider provider = entry == null ? findServiceProvider(trimmed) : null; + ModelProvider provider = entry == null ? findServiceProvider(trimmed, context) : null; if (entry == null && provider == null) { throw new IllegalArgumentException(buildNotFoundMessage(trimmed)); } @@ -107,10 +145,10 @@ public static Model resolve(String modelId) { try { Model created; if (entry != null) { - created = entry.factory().create(trimmed); + created = entry.factory().create(trimmed, context); Objects.requireNonNull(created, "ModelFactory returned null for: " + trimmed); } else { - created = provider.create(trimmed); + created = provider.create(trimmed, context); Objects.requireNonNull( created, "ModelProvider " @@ -118,7 +156,9 @@ public static Model resolve(String modelId) { + " returned null for: " + trimmed); } - resolvedCache.put(trimmed, created); + if (cacheKey != null) { + resolvedCache.put(cacheKey, created); + } return created; } catch (RuntimeException e) { throw new IllegalArgumentException( @@ -127,10 +167,19 @@ public static Model resolve(String modelId) { } /** - * Returns {@code true} if {@link #resolve(String)} can find a named model or a matching factory - * pattern (without creating an instance). + * Returns {@code true} if {@link #resolve(String)} can find a named model or a matching factory/ + * provider pattern (without creating an instance). */ public static boolean canResolve(String modelId) { + return canResolve(modelId, ModelCreationContext.empty()); + } + + /** + * Returns {@code true} if {@link #resolve(String, ModelCreationContext)} can find a named model + * or a matching factory/provider pattern (without creating an instance). + */ + public static boolean canResolve(String modelId, ModelCreationContext context) { + Objects.requireNonNull(context, "context"); if (modelId == null) { return false; } @@ -141,7 +190,8 @@ public static boolean canResolve(String modelId) { if (namedModels.containsKey(trimmed)) { return true; } - return findMatchingUserEntry(trimmed) != null || findServiceProvider(trimmed) != null; + return findMatchingUserEntry(trimmed) != null + || findServiceProvider(trimmed, context) != null; } /** @@ -168,7 +218,14 @@ public interface ModelFactory { Model create(String modelId); } - private record ProviderEntry(Pattern pattern, ModelFactory factory) {} + @FunctionalInterface + public interface ContextModelFactory { + Model create(String modelId, ModelCreationContext context); + } + + private record ProviderEntry(Pattern pattern, ContextModelFactory factory) {} + + private record ModelCacheKey(String modelId, String cacheIdentity) {} private static ProviderEntry findMatchingUserEntry(String modelId) { for (ProviderEntry e : userFactories) { @@ -179,12 +236,12 @@ private static ProviderEntry findMatchingUserEntry(String modelId) { return null; } - private static ModelProvider findServiceProvider(String modelId) { + private static ModelProvider findServiceProvider(String modelId, ModelCreationContext context) { ModelProvider matched = null; for (ModelProvider provider : loadServiceProviders()) { boolean supports; try { - supports = provider.supports(modelId); + supports = provider.supports(modelId, context); } catch (RuntimeException | LinkageError e) { logger.warn( "Skipping ModelProvider {} because supports(\"{}\") failed", @@ -210,6 +267,76 @@ private static ModelProvider findServiceProvider(String modelId) { return matched; } + /** + * Builds the registry cache key for a model resolution. + * + *

The rules intentionally avoid treating arbitrary context objects as comparable: + * + *

    + *
  • Empty contexts keep the legacy behavior and cache only by {@code modelId}. + *
  • Non-empty contexts are not cached unless {@link CachePolicy#ENABLED} is selected. + *
  • An explicit {@code cacheId} is the authoritative identity for complex contexts. + *
  • Without {@code cacheId}, only standard simple fields are fingerprinted. Options and + * components are opaque and must not rely on deep hashing or object {@code hashCode()}. + *
+ */ + private static ModelCacheKey cacheKey(String modelId, ModelCreationContext context) { + if (context.isEmpty()) { + return new ModelCacheKey(modelId, "legacy"); + } + if (context.getCachePolicy() == CachePolicy.DISABLED + || context.getCachePolicy() == CachePolicy.DEFAULT) { + return null; + } + + String cacheId = context.getCacheId(); + if (cacheId != null) { + return new ModelCacheKey(modelId, "explicit:" + cacheId); + } + if (context.hasOpaqueCacheInputs()) { + throw new IllegalArgumentException( + "ModelCreationContext cachePolicy(ENABLED) with options or components " + + "requires an explicit cacheId"); + } + return new ModelCacheKey(modelId, "standard:" + standardContextFingerprint(context)); + } + + /** + * Creates a cache fingerprint from provider-neutral simple fields only. + * + *

API keys are never written to the key in plain text. The digest is not intended as a + * security boundary; it only prevents accidental secret exposure in heap dumps, logs, and test + * diagnostics. + */ + private static String standardContextFingerprint(ModelCreationContext context) { + return "apiKeySha256=" + + sha256Hex(context.getApiKey()) + + "|baseUrl=" + + valueOf(context.getBaseUrl()) + + "|endpointPath=" + + valueOf(context.getEndpointPath()) + + "|stream=" + + valueOf(context.getStream()) + + "|enableThinking=" + + valueOf(context.getEnableThinking()); + } + + private static String valueOf(Object value) { + return value == null ? "" : value.toString(); + } + + private static String sha256Hex(String value) { + if (value == null) { + return ""; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + private static List loadServiceProviders() { List providers = serviceProviders; if (providers != null) { diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/spi/ModelProvider.java b/agentscope-core/src/main/java/io/agentscope/core/model/spi/ModelProvider.java index 52fb407b8..a433a3ef3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/spi/ModelProvider.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/spi/ModelProvider.java @@ -16,6 +16,7 @@ package io.agentscope.core.model.spi; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; /** Service Provider Interface for model adapters discovered from extension modules. */ public interface ModelProvider { @@ -23,9 +24,22 @@ public interface ModelProvider { /** Provider identifier, for example {@code openai}. */ String providerId(); - /** Returns whether this provider can create a model for the full model id. */ + /** + * Returns whether this provider can create a model for the full model id, + * for example {@code openai:gpt-4o}. + */ boolean supports(String modelId); + /** Returns whether this provider can create a model for the full model id and context. */ + default boolean supports(String modelId, ModelCreationContext context) { + return supports(modelId); + } + /** Creates a model for the full model id. */ Model create(String modelId); + + /** Creates a model for the full model id using a provider-neutral context. */ + default Model create(String modelId, ModelCreationContext context) { + return create(modelId); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java index c2542ef78..f9b965c2c 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -89,6 +90,162 @@ void resolve_caching_returnsSameInstance() { assertSame(a, b); } + @Test + void resolve_withNonEmptyDefaultContext_doesNotCache() { + ModelRegistry.registerFactory("local:(.+)", (id, context) -> new StubModel(id)); + ModelCreationContext context = ModelCreationContext.builder().apiKey("key-a").build(); + + Model a = ModelRegistry.resolve("local:alpha", context); + Model b = ModelRegistry.resolve("local:alpha", context); + + assertNotSame(a, b); + } + + @Test + void resolve_withDisabledCachePolicy_doesNotCache() { + ModelRegistry.registerFactory("local:(.+)", (id, context) -> new StubModel(id)); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("key-a") + .cachePolicy(CachePolicy.DISABLED) + .build(); + + Model a = ModelRegistry.resolve("local:alpha", context); + Model b = ModelRegistry.resolve("local:alpha", context); + + assertNotSame(a, b); + } + + @Test + void resolve_withEnabledCachePolicyAndCacheId_usesCacheId() { + ModelRegistry.registerFactory("local:(.+)", (id, context) -> new StubModel(id)); + ModelCreationContext first = + ModelCreationContext.builder() + .apiKey("key-a") + .cachePolicy(CachePolicy.ENABLED) + .cacheId("tenant-a") + .build(); + ModelCreationContext sameCacheIdDifferentSecret = + ModelCreationContext.builder() + .apiKey("key-b") + .cachePolicy(CachePolicy.ENABLED) + .cacheId("tenant-a") + .build(); + ModelCreationContext differentCacheId = + ModelCreationContext.builder() + .apiKey("key-a") + .cachePolicy(CachePolicy.ENABLED) + .cacheId("tenant-b") + .build(); + + Model a = ModelRegistry.resolve("local:alpha", first); + Model b = ModelRegistry.resolve("local:alpha", sameCacheIdDifferentSecret); + Model c = ModelRegistry.resolve("local:alpha", differentCacheId); + + assertSame(a, b); + assertNotSame(a, c); + } + + @Test + void resolve_withEnabledCachePolicyAndStandardFields_derivesSafeCacheKey() { + ModelRegistry.registerFactory("local:(.+)", (id, context) -> new StubModel(id)); + ModelCreationContext first = + ModelCreationContext.builder() + .apiKey("key-a") + .baseUrl("https://one.example") + .stream(false) + .cachePolicy(CachePolicy.ENABLED) + .build(); + ModelCreationContext same = + ModelCreationContext.builder() + .apiKey("key-a") + .baseUrl("https://one.example") + .stream(false) + .cachePolicy(CachePolicy.ENABLED) + .build(); + ModelCreationContext differentApiKey = + ModelCreationContext.builder() + .apiKey("key-b") + .baseUrl("https://one.example") + .stream(false) + .cachePolicy(CachePolicy.ENABLED) + .build(); + + Model a = ModelRegistry.resolve("local:alpha", first); + Model b = ModelRegistry.resolve("local:alpha", same); + Model c = ModelRegistry.resolve("local:alpha", differentApiKey); + + assertSame(a, b); + assertNotSame(a, c); + } + + @Test + void resolve_withEnabledCachePolicyAndOpaqueInputs_requiresCacheId() { + ModelRegistry.registerFactory("local:(.+)", (id, context) -> new StubModel(id)); + ModelCreationContext withOption = + ModelCreationContext.builder() + .option("custom", "value") + .cachePolicy(CachePolicy.ENABLED) + .build(); + ModelCreationContext withComponent = + ModelCreationContext.builder() + .component(StringBuilder.class, new StringBuilder("opaque")) + .cachePolicy(CachePolicy.ENABLED) + .build(); + + assertThrows( + IllegalArgumentException.class, + () -> ModelRegistry.resolve("local:alpha", withOption)); + assertThrows( + IllegalArgumentException.class, + () -> ModelRegistry.resolve("local:alpha", withComponent)); + } + + @Test + void registerFactory_contextAwareFactory_receivesContext() { + ModelRegistry.registerFactory( + "local:(.+)", (id, context) -> new StubModel(context.getBaseUrl() + "/" + id)); + + Model model = + ModelRegistry.resolve( + "local:alpha", + ModelCreationContext.builder().baseUrl("https://example.com").build()); + + assertTrue(model.getModelName().contains("https://example.com/local:alpha")); + } + + @Test + void modelCreationContext_toStringRedactsApiKey() { + ModelCreationContext context = ModelCreationContext.builder().apiKey("secret-key").build(); + + assertFalse(context.toString().contains("secret-key")); + assertTrue(context.toString().contains("[REDACTED]")); + } + + @Test + void modelCreationContext_toBuilderCopiesExistingValues() { + StringBuilder component = new StringBuilder("component"); + ModelCreationContext original = + ModelCreationContext.builder() + .apiKey("secret-key") + .baseUrl("https://example.com") + .option("custom", "value") + .component(StringBuilder.class, component) + .cachePolicy(CachePolicy.ENABLED) + .cacheId("tenant-a") + .build(); + + ModelCreationContext copy = original.toBuilder().baseUrl("https://other.example").build(); + + assertTrue(original.getBaseUrl().equals("https://example.com")); + assertTrue(copy.getApiKey().equals("secret-key")); + assertTrue(copy.getBaseUrl().equals("https://other.example")); + assertTrue(copy.option("custom", String.class).equals("value")); + assertSame(component, copy.component(StringBuilder.class)); + assertTrue(copy.getCachePolicy() == CachePolicy.ENABLED); + assertTrue(copy.getCacheId().equals("tenant-a")); + } + @Test void registerFactory_userFactory_resolvesMatchingPattern() { Model custom = new StubModel("custom-openai"); diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java index 48048215e..69edc8010 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelTimeoutRetryTest.java @@ -323,7 +323,7 @@ public String getModelName() { /** * Helper method that applies timeout and retry logic to a response Flux, mimicking the - * behavior in DashScopeChatModel and OpenAIChatModel. + * behavior in provider extension models. */ private Flux applyTimeoutAndRetry( Flux responseFlux, GenerateOptions options) { diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 1b436790e..ae327c8f2 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -427,6 +427,34 @@ ${project.version} + + + io.agentscope + agentscope-openai-spring-boot-starter + ${project.version} + + + + + io.agentscope + agentscope-dashscope-spring-boot-starter + ${project.version} + + + + + io.agentscope + agentscope-gemini-spring-boot-starter + ${project.version} + + + + + io.agentscope + agentscope-anthropic-spring-boot-starter + ${project.version} + + io.agentscope diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelFactory.java deleted file mode 100644 index 0de2ef6c9..000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicChatModelFactory.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.extensions.model.anthropic; - -import io.agentscope.core.model.Model; - -/** Factory used by integration layers that should not depend on Anthropic internals directly. */ -public final class AnthropicChatModelFactory { - - private AnthropicChatModelFactory() {} - - /** - * Creates an Anthropic chat model with the first-phase Spring Boot configuration surface. - * - * @param apiKey Anthropic API key - * @param modelName Anthropic model name - * @param stream whether streaming is enabled - * @param baseUrl optional custom base URL - * @return created model - */ - public static Model create(String apiKey, String modelName, boolean stream, String baseUrl) { - AnthropicChatModel.Builder builder = - AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream(stream); - - if (baseUrl != null && !baseUrl.isEmpty()) { - builder.baseUrl(baseUrl); - } - - return builder.build(); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java index 513b41b42..cdf1ade7f 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/main/java/io/agentscope/extensions/model/anthropic/AnthropicModelProvider.java @@ -15,8 +15,12 @@ */ package io.agentscope.extensions.model.anthropic; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.spi.ModelProvider; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.anthropic.formatter.AnthropicBaseFormatter; import java.util.regex.Pattern; /** Anthropic provider registered through {@link java.util.ServiceLoader}. */ @@ -24,6 +28,7 @@ public final class AnthropicModelProvider implements ModelProvider { private static final String PREFIX = "anthropic:"; private static final Pattern MODEL_ID = Pattern.compile("anthropic:.+"); + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; @Override public String providerId() { @@ -37,12 +42,69 @@ public boolean supports(String modelId) { @Override public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { if (!supports(modelId)) { throw new IllegalArgumentException("Unsupported Anthropic model id: " + modelId); } String modelName = modelId.substring(PREFIX.length()); - String apiKey = System.getenv("ANTHROPIC_API_KEY"); - return AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream(true) - .build(); + String apiKey = firstNonBlank(context.getApiKey(), System.getenv("ANTHROPIC_API_KEY")); + AnthropicChatModel.Builder builder = + AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + context.getStream() != null ? context.getStream() : true); + String baseUrl = trimToNull(context.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + applyAdvancedOptions(builder, context); + return builder.build(); + } + + private static void applyAdvancedOptions( + AnthropicChatModel.Builder builder, ModelCreationContext context) { + GenerateOptions defaultOptions = context.component(GenerateOptions.class); + if (defaultOptions != null) { + builder.defaultOptions(defaultOptions); + } + ProxyConfig proxyConfig = context.component(ProxyConfig.class); + if (proxyConfig != null) { + builder.proxy(proxyConfig); + } + AnthropicBaseFormatter formatter = context.component(AnthropicBaseFormatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + } + + private static String firstNonBlank(String preferred, String fallback) { + String normalized = trimToNull(preferred); + return normalized != null ? normalized : trimToNull(fallback); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Integer intOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a number"); } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java index 070b94509..16546d32b 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-anthropic/src/test/java/io/agentscope/extensions/model/anthropic/AnthropicModelProviderTest.java @@ -15,11 +15,16 @@ */ package io.agentscope.extensions.model.anthropic; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.transport.ProxyConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -47,6 +52,26 @@ void createRejectsUnsupportedModelIdsBeforeReadingEnvironment() { assertThrows(IllegalArgumentException.class, () -> provider.create(null)); } + @Test + void createUsesModelCreationContext() { + AnthropicModelProvider provider = new AnthropicModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-anthropic-key") + .baseUrl("https://anthropic.example.com") + .stream(false) + .component(GenerateOptions.class, GenerateOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 200000) + .build(); + + Model model = provider.create("anthropic:claude-sonnet-4.5", context); + + assertTrue(model instanceof AnthropicChatModel); + assertTrue(model.getModelName().equals("claude-sonnet-4.5")); + assertEquals(200000, model.getContextWindowSize()); + } + @Test void modelRegistryFindsAnthropicProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("anthropic:claude-sonnet-4.5")); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModelFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModelFactory.java deleted file mode 100644 index cfc9697c2..000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModelFactory.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.extensions.model.dashscope; - -import io.agentscope.core.model.Model; - -/** Factory used by integration layers that should not depend on DashScope internals directly. */ -public final class DashScopeChatModelFactory { - - private DashScopeChatModelFactory() {} - - /** - * Creates a DashScope chat model with the first-phase Spring Boot configuration surface. - * - * @param apiKey DashScope API key - * @param modelName DashScope model name - * @param stream whether streaming is enabled - * @param baseUrl optional custom base URL - * @param enableThinking optional thinking-mode flag - * @return created model - */ - public static Model create( - String apiKey, - String modelName, - boolean stream, - String baseUrl, - Boolean enableThinking) { - DashScopeChatModel.Builder builder = - DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream(stream); - - if (baseUrl != null && !baseUrl.isEmpty()) { - builder.baseUrl(baseUrl); - } - - if (enableThinking != null) { - builder.enableThinking(enableThinking); - } - - return builder.build(); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeModelProvider.java index 45d77f9ef..9972cb173 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeModelProvider.java @@ -15,8 +15,16 @@ */ package io.agentscope.extensions.model.dashscope; +import io.agentscope.core.formatter.Formatter; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.spi.ModelProvider; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.dashscope.dto.DashScopeMessage; +import io.agentscope.extensions.model.dashscope.dto.DashScopeRequest; +import io.agentscope.extensions.model.dashscope.dto.DashScopeResponse; import java.util.regex.Pattern; /** DashScope provider registered through {@link java.util.ServiceLoader}. */ @@ -25,6 +33,12 @@ public final class DashScopeModelProvider implements ModelProvider { private static final String PREFIX = "dashscope:"; private static final Pattern DASH_SCOPE_MODEL_ID = Pattern.compile("dashscope:.+"); private static final Pattern QWEN_SHORT_MODEL_ID = Pattern.compile("qwen.+"); + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; + private static final String OPTION_ENABLE_ENCRYPT = "enableEncrypt"; + private static final String OPTION_ENABLE_SEARCH = "enableSearch"; + private static final String OPTION_ENDPOINT_TYPE = "endpointType"; + private static final String OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS = + "nativeStructuredOutputWithTools"; @Override public String providerId() { @@ -40,18 +54,139 @@ public boolean supports(String modelId) { @Override public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { if (!supports(modelId)) { throw new IllegalArgumentException("Unsupported DashScope model id: " + modelId); } String modelName = modelId.startsWith(PREFIX) ? modelId.substring(PREFIX.length()) : modelId; - String apiKey = System.getenv("DASHSCOPE_API_KEY"); - if (apiKey == null || apiKey.isBlank()) { + String apiKey = firstNonBlank(context.getApiKey(), System.getenv("DASHSCOPE_API_KEY")); + if (apiKey == null) { throw new IllegalStateException( "Environment variable DASHSCOPE_API_KEY is required to auto-create model: " + modelId); } - return DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream(true) - .build(); + DashScopeChatModel.Builder builder = + DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + context.getStream() != null ? context.getStream() : true); + String baseUrl = trimToNull(context.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + if (context.getEnableThinking() != null) { + builder.enableThinking(context.getEnableThinking()); + } + applyAdvancedOptions(builder, context); + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static void applyAdvancedOptions( + DashScopeChatModel.Builder builder, ModelCreationContext context) { + GenerateOptions defaultOptions = context.component(GenerateOptions.class); + if (defaultOptions != null) { + builder.defaultOptions(defaultOptions); + } + HttpTransport httpTransport = context.component(HttpTransport.class); + if (httpTransport != null) { + builder.httpTransport(httpTransport); + } + ProxyConfig proxyConfig = context.component(ProxyConfig.class); + if (proxyConfig != null) { + builder.proxy(proxyConfig); + } + Formatter formatter = + (Formatter) + findAssignableComponent(context, Formatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + Boolean enableSearch = booleanOption(context, OPTION_ENABLE_SEARCH); + if (enableSearch != null) { + builder.enableSearch(enableSearch); + } + EndpointType endpointType = endpointTypeOption(context, OPTION_ENDPOINT_TYPE); + if (endpointType != null) { + builder.endpointType(endpointType); + } + Boolean enableEncrypt = booleanOption(context, OPTION_ENABLE_ENCRYPT); + if (enableEncrypt != null) { + builder.enableEncrypt(enableEncrypt); + } + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + Boolean nativeStructuredOutputWithTools = + booleanOption(context, OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS); + if (nativeStructuredOutputWithTools != null) { + builder.nativeStructuredOutputWithTools(nativeStructuredOutputWithTools); + } + } + + private static String firstNonBlank(String preferred, String fallback) { + String normalized = trimToNull(preferred); + return normalized != null ? normalized : trimToNull(fallback); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Object findAssignableComponent( + ModelCreationContext context, Class componentType) { + for (Object value : context.getComponents().values()) { + if (componentType.isInstance(value)) { + return value; + } + } + return null; + } + + private static Integer intOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a number"); + } + + private static Boolean booleanOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Boolean bool) { + return bool; + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a boolean"); + } + + private static EndpointType endpointTypeOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof EndpointType endpointType) { + return endpointType; + } + if (value instanceof String text) { + return EndpointType.valueOf(text.trim()); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be an EndpointType or string"); } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/test/java/io/agentscope/extensions/model/dashscope/DashScopeModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/test/java/io/agentscope/extensions/model/dashscope/DashScopeModelProviderTest.java index ec0c622ef..1dc18affe 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/test/java/io/agentscope/extensions/model/dashscope/DashScopeModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/test/java/io/agentscope/extensions/model/dashscope/DashScopeModelProviderTest.java @@ -15,11 +15,16 @@ */ package io.agentscope.extensions.model.dashscope; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.transport.ProxyConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -50,6 +55,30 @@ void createRejectsUnsupportedModelIdsBeforeReadingEnvironment() { assertThrows(IllegalArgumentException.class, () -> provider.create(null)); } + @Test + void createUsesModelCreationContext() { + DashScopeModelProvider provider = new DashScopeModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-dashscope-key") + .baseUrl("https://dashscope.example.com") + .stream(false) + .enableThinking(true) + .component(GenerateOptions.class, GenerateOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 128000) + .option("enableSearch", true) + .option("endpointType", EndpointType.TEXT) + .option("nativeStructuredOutputWithTools", true) + .build(); + + Model model = provider.create("dashscope:qwen-max", context); + + assertTrue(model instanceof DashScopeChatModel); + assertTrue(model.getModelName().equals("qwen-max")); + assertEquals(128000, model.getContextWindowSize()); + } + @Test void modelRegistryFindsDashScopeProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("dashscope:qwen-max")); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiChatModelFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiChatModelFactory.java deleted file mode 100644 index 6f1c312f1..000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiChatModelFactory.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.extensions.model.gemini; - -import io.agentscope.core.model.Model; - -/** Factory used by integration layers that should not depend on Gemini internals directly. */ -public final class GeminiChatModelFactory { - - private GeminiChatModelFactory() {} - - /** - * Creates a Gemini chat model with the first-phase Spring Boot configuration surface. - * - * @param apiKey Gemini API key - * @param modelName Gemini model name - * @param stream whether streaming is enabled - * @param baseUrl optional custom base URL - * @param project optional Google Cloud project ID for Vertex AI - * @param location optional Google Cloud location for Vertex AI - * @param vertexAI optional flag for using Vertex AI APIs - * @return created model - */ - public static Model create( - String apiKey, - String modelName, - boolean stream, - String baseUrl, - String project, - String location, - Boolean vertexAI) { - GeminiChatModel.Builder builder = - GeminiChatModel.builder() - .apiKey(apiKey) - .modelName(modelName) - .streamEnabled(stream) - .project(project) - .location(location); - - if (baseUrl != null && !baseUrl.isEmpty()) { - builder.baseUrl(baseUrl); - } - - if (vertexAI != null) { - builder.vertexAI(vertexAI); - } - - return builder.build(); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiModelProvider.java index 920edefc6..eaaf494a6 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/main/java/io/agentscope/extensions/model/gemini/GeminiModelProvider.java @@ -15,8 +15,18 @@ */ package io.agentscope.extensions.model.gemini; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.genai.types.ClientOptions; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.HttpOptions; +import io.agentscope.core.formatter.Formatter; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.spi.ModelProvider; +import io.agentscope.core.model.transport.ProxyConfig; import java.util.regex.Pattern; /** Gemini provider registered through {@link java.util.ServiceLoader}. */ @@ -24,6 +34,10 @@ public final class GeminiModelProvider implements ModelProvider { private static final String PREFIX = "gemini:"; private static final Pattern MODEL_ID = Pattern.compile("gemini:.+"); + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; + private static final String OPTION_PROJECT = "project"; + private static final String OPTION_LOCATION = "location"; + private static final String OPTION_VERTEX_AI = "vertexAI"; @Override public String providerId() { @@ -37,20 +51,147 @@ public boolean supports(String modelId) { @Override public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { if (!supports(modelId)) { throw new IllegalArgumentException("Unsupported Gemini model id: " + modelId); } String modelName = modelId.substring(PREFIX.length()); - String apiKey = System.getenv("GEMINI_API_KEY"); - if (apiKey == null || apiKey.isBlank()) { - throw new IllegalStateException( - "Environment variable GEMINI_API_KEY is required to auto-create model: " - + modelId); - } - return GeminiChatModel.builder() - .apiKey(apiKey) - .modelName(modelName) - .streamEnabled(true) - .build(); + GeminiChatModel.Builder builder = + GeminiChatModel.builder() + .modelName(modelName) + .streamEnabled(context.getStream() != null ? context.getStream() : true); + + // true to use Vertex AI, false/null for Gemini API + Boolean vertexAI = booleanOption(context, OPTION_VERTEX_AI); + if (Boolean.TRUE.equals(vertexAI)) { + String project = stringOption(context, OPTION_PROJECT); + if (project == null) { + throw new IllegalStateException( + "ModelCreationContext option project is required to auto-create Vertex AI" + + " model: " + + modelId); + } + builder.project(project) + .location(stringOption(context, OPTION_LOCATION)) + .vertexAI(true); + + // Optional Vertex AI credential override. If omitted, the Google GenAI SDK falls back + // to Application Default Credentials. + GoogleCredentials credentials = context.component(GoogleCredentials.class); + if (credentials != null) { + builder.credentials(credentials); + } + } else { + String apiKey = firstNonBlank(context.getApiKey(), System.getenv("GEMINI_API_KEY")); + if (apiKey == null) { + throw new IllegalStateException( + "Environment variable GEMINI_API_KEY is required to auto-create model: " + + modelId); + } + builder.apiKey(apiKey); + if (Boolean.FALSE.equals(vertexAI)) { + builder.vertexAI(false); + } + } + String baseUrl = trimToNull(context.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + applyAdvancedOptions(builder, context); + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static void applyAdvancedOptions( + GeminiChatModel.Builder builder, ModelCreationContext context) { + HttpOptions httpOptions = context.component(HttpOptions.class); + if (httpOptions != null) { + builder.httpOptions(httpOptions); + } + ClientOptions clientOptions = context.component(ClientOptions.class); + if (clientOptions != null) { + builder.clientOptions(clientOptions); + } + GenerateOptions defaultOptions = context.component(GenerateOptions.class); + if (defaultOptions != null) { + builder.defaultOptions(defaultOptions); + } + ProxyConfig proxyConfig = context.component(ProxyConfig.class); + if (proxyConfig != null) { + builder.proxy(proxyConfig); + } + Formatter formatter = + (Formatter) + findAssignableComponent(context, Formatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + } + + private static String firstNonBlank(String preferred, String fallback) { + String normalized = trimToNull(preferred); + return normalized != null ? normalized : trimToNull(fallback); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Object findAssignableComponent( + ModelCreationContext context, Class componentType) { + for (Object value : context.getComponents().values()) { + if (componentType.isInstance(value)) { + return value; + } + } + return null; + } + + private static Integer intOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a number"); + } + + private static String stringOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof String text) { + return trimToNull(text); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a string"); + } + + private static Boolean booleanOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Boolean bool) { + return bool; + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a boolean"); } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiChatModelTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiChatModelTest.java index 7b8b8e68a..767c69e8f 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiChatModelTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiChatModelTest.java @@ -323,20 +323,6 @@ void testBaseUrlConfiguration() throws Exception { assertEquals(baseUrl, getHttpOptions(model).baseUrl().orElseThrow()); } - @Test - @DisplayName("Should configure custom base URL through factory") - void testFactoryBaseUrlConfiguration() throws Exception { - String baseUrl = "https://factory-gemini-endpoint.example"; - - GeminiChatModel model = - (GeminiChatModel) - GeminiChatModelFactory.create( - mockApiKey, "gemini-2.0-flash", true, baseUrl, null, null, null); - - assertNotNull(model); - assertEquals(baseUrl, getHttpOptions(model).baseUrl().orElseThrow()); - } - @Test @DisplayName("Should override HTTP options base URL while preserving other settings") void testBaseUrlOverridesHttpOptionsBaseUrl() throws Exception { diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiModelProviderTest.java index 88befe9ed..7a8e37d57 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-gemini/src/test/java/io/agentscope/extensions/model/gemini/GeminiModelProviderTest.java @@ -15,11 +15,18 @@ */ package io.agentscope.extensions.model.gemini; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.genai.types.HttpOptions; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.transport.ProxyConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -47,6 +54,46 @@ void createRejectsUnsupportedModelIdsBeforeReadingEnvironment() { assertThrows(IllegalArgumentException.class, () -> provider.create(null)); } + @Test + void createUsesGeminiApiModelCreationContext() { + GeminiModelProvider provider = new GeminiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-gemini-key") + .baseUrl("https://gemini.example.com") + .stream(false) + .component(HttpOptions.class, HttpOptions.builder().build()) + .component(GenerateOptions.class, GenerateOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 1000000) + .option("vertexAI", false) + .build(); + + Model model = provider.create("gemini:gemini-2.0-flash", context); + + assertTrue(model instanceof GeminiChatModel); + assertTrue(model.getModelName().equals("gemini-2.0-flash")); + } + + @Test + void createUsesVertexAiModelCreationContext() { + GeminiModelProvider provider = new GeminiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder().stream(false) + .component(GoogleCredentials.class, GoogleCredentials.create(null)) + .option("contextWindowSize", 128000) + .option("project", "test-project") + .option("location", "us-central1") + .option("vertexAI", true) + .build(); + + Model model = provider.create("gemini:gemini-2.0-flash", context); + + assertTrue(model instanceof GeminiChatModel); + assertTrue(model.getModelName().equals("gemini-2.0-flash")); + assertEquals(128000, model.getContextWindowSize()); + } + @Test void modelRegistryFindsGeminiProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("gemini:gemini-2.0-flash")); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModelFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModelFactory.java deleted file mode 100644 index b149c61a4..000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModelFactory.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.extensions.model.ollama; - -import io.agentscope.core.model.Model; - -/** Factory used by integration layers that should not depend on Ollama internals directly. */ -public final class OllamaChatModelFactory { - - private OllamaChatModelFactory() {} - - /** - * Creates an Ollama chat model with the stable first-phase configuration surface. - * - * @param modelName Ollama model name - * @param baseUrl optional Ollama server base URL - * @return created model - */ - public static Model create(String modelName, String baseUrl) { - OllamaChatModel.Builder builder = OllamaChatModel.builder().modelName(modelName); - if (baseUrl != null && !baseUrl.isEmpty()) { - builder.baseUrl(baseUrl); - } - return builder.build(); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaModelProvider.java index 75869f261..323a6e3a8 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaModelProvider.java @@ -15,8 +15,16 @@ */ package io.agentscope.extensions.model.ollama; +import io.agentscope.core.formatter.Formatter; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.spi.ModelProvider; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.ollama.dto.OllamaMessage; +import io.agentscope.extensions.model.ollama.dto.OllamaRequest; +import io.agentscope.extensions.model.ollama.dto.OllamaResponse; +import io.agentscope.extensions.model.ollama.options.OllamaOptions; import java.util.regex.Pattern; /** Ollama provider registered through {@link java.util.ServiceLoader}. */ @@ -24,6 +32,7 @@ public final class OllamaModelProvider implements ModelProvider { private static final String PREFIX = "ollama:"; private static final Pattern MODEL_ID = Pattern.compile("ollama:.+"); + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; @Override public String providerId() { @@ -37,14 +46,84 @@ public boolean supports(String modelId) { @Override public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { if (!supports(modelId)) { throw new IllegalArgumentException("Unsupported Ollama model id: " + modelId); } String modelName = modelId.substring(PREFIX.length()); - String baseUrl = System.getenv("OLLAMA_BASE_URL"); - if (baseUrl == null || baseUrl.isBlank()) { + String baseUrl = firstNonBlank(context.getBaseUrl(), System.getenv("OLLAMA_BASE_URL")); + if (baseUrl == null) { baseUrl = OllamaHttpClient.DEFAULT_BASE_URL; } - return OllamaChatModel.builder().modelName(modelName).baseUrl(baseUrl).build(); + OllamaChatModel.Builder builder = + OllamaChatModel.builder().modelName(modelName).baseUrl(baseUrl); + applyAdvancedOptions(builder, context); + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static void applyAdvancedOptions( + OllamaChatModel.Builder builder, ModelCreationContext context) { + OllamaOptions defaultOptions = context.component(OllamaOptions.class); + if (defaultOptions != null) { + builder.defaultOptions(defaultOptions); + } + HttpTransport httpTransport = context.component(HttpTransport.class); + if (httpTransport != null) { + builder.httpTransport(httpTransport); + } + ProxyConfig proxyConfig = context.component(ProxyConfig.class); + if (proxyConfig != null) { + builder.proxy(proxyConfig); + } + Formatter formatter = + (Formatter) + findAssignableComponent(context, Formatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + } + + private static String firstNonBlank(String preferred, String fallback) { + String normalized = trimToNull(preferred); + return normalized != null ? normalized : trimToNull(fallback); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Object findAssignableComponent( + ModelCreationContext context, Class componentType) { + for (Object value : context.getComponents().values()) { + if (componentType.isInstance(value)) { + return value; + } + } + return null; + } + + private static Integer intOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a number"); } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaModelProviderTest.java index b666aba84..c99c1bd94 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaModelProviderTest.java @@ -15,11 +15,16 @@ */ package io.agentscope.extensions.model.ollama; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.ollama.options.OllamaOptions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -49,6 +54,24 @@ void createRejectsUnsupportedModelIds() { assertThrows(IllegalArgumentException.class, () -> provider.create(null)); } + @Test + void createUsesModelCreationContext() { + OllamaModelProvider provider = new OllamaModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .baseUrl("http://ollama.example.com") + .component(OllamaOptions.class, OllamaOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 128000) + .build(); + + Model model = provider.create("ollama:llama3", context); + + assertTrue(model instanceof OllamaChatModel); + assertTrue(model.getModelName().equals("llama3")); + assertEquals(128000, model.getContextWindowSize()); + } + @Test void modelRegistryFindsOllamaProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("ollama:llama3")); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModelFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModelFactory.java deleted file mode 100644 index aa6c51edd..000000000 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModelFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.extensions.model.openai; - -import io.agentscope.core.model.Model; - -/** Factory used by integration layers that should not depend on OpenAI internals directly. */ -public final class OpenAIChatModelFactory { - - private OpenAIChatModelFactory() {} - - /** - * Creates an OpenAI chat model with the first-phase Spring Boot configuration surface. - * - * @param apiKey OpenAI API key - * @param modelName OpenAI model name - * @param stream whether streaming is enabled - * @param baseUrl optional custom base URL - * @param endpointPath optional custom endpoint path - * @return created model - */ - public static Model create( - String apiKey, String modelName, boolean stream, String baseUrl, String endpointPath) { - OpenAIChatModel.Builder builder = - OpenAIChatModel.builder().apiKey(apiKey).modelName(modelName).stream(stream); - - if (baseUrl != null && !baseUrl.isEmpty()) { - builder.baseUrl(baseUrl); - } - - if (endpointPath != null && !endpointPath.isEmpty()) { - builder.endpointPath(endpointPath); - } - - return builder.build(); - } -} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIModelProvider.java index 67cf4c122..31cc55951 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIModelProvider.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIModelProvider.java @@ -15,8 +15,16 @@ */ package io.agentscope.extensions.model.openai; +import io.agentscope.core.formatter.Formatter; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.spi.ModelProvider; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.openai.dto.OpenAIMessage; +import io.agentscope.extensions.model.openai.dto.OpenAIRequest; +import io.agentscope.extensions.model.openai.dto.OpenAIResponse; import java.util.regex.Pattern; /** OpenAI provider registered through {@link java.util.ServiceLoader}. */ @@ -24,6 +32,9 @@ public final class OpenAIModelProvider implements ModelProvider { private static final String PREFIX = "openai:"; private static final Pattern MODEL_ID = Pattern.compile("openai:.+"); + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; + private static final String OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS = + "nativeStructuredOutputWithTools"; @Override public String providerId() { @@ -37,16 +48,112 @@ public boolean supports(String modelId) { @Override public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { if (!supports(modelId)) { throw new IllegalArgumentException("Unsupported OpenAI model id: " + modelId); } String modelName = modelId.substring(PREFIX.length()); - String apiKey = System.getenv("OPENAI_API_KEY"); - if (apiKey == null || apiKey.isBlank()) { + String apiKey = firstNonBlank(context.getApiKey(), System.getenv("OPENAI_API_KEY")); + if (apiKey == null) { throw new IllegalStateException( "Environment variable OPENAI_API_KEY is required to auto-create model: " + modelId); } - return OpenAIChatModel.builder().apiKey(apiKey).modelName(modelName).stream(true).build(); + OpenAIChatModel.Builder builder = + OpenAIChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + context.getStream() != null ? context.getStream() : true); + String baseUrl = trimToNull(context.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + String endpointPath = trimToNull(context.getEndpointPath()); + if (endpointPath != null) { + builder.endpointPath(endpointPath); + } + applyAdvancedOptions(builder, context); + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static void applyAdvancedOptions( + OpenAIChatModel.Builder builder, ModelCreationContext context) { + GenerateOptions generateOptions = context.component(GenerateOptions.class); + if (generateOptions != null) { + builder.generateOptions(generateOptions); + } + HttpTransport httpTransport = context.component(HttpTransport.class); + if (httpTransport != null) { + builder.httpTransport(httpTransport); + } + ProxyConfig proxyConfig = context.component(ProxyConfig.class); + if (proxyConfig != null) { + builder.proxy(proxyConfig); + } + Formatter formatter = + (Formatter) + findAssignableComponent(context, Formatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + Boolean nativeStructuredOutputWithTools = + booleanOption(context, OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS); + if (nativeStructuredOutputWithTools != null) { + builder.nativeStructuredOutputWithTools(nativeStructuredOutputWithTools); + } + } + + private static String firstNonBlank(String preferred, String fallback) { + String normalized = trimToNull(preferred); + return normalized != null ? normalized : trimToNull(fallback); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Object findAssignableComponent( + ModelCreationContext context, Class componentType) { + for (Object value : context.getComponents().values()) { + if (componentType.isInstance(value)) { + return value; + } + } + return null; + } + + private static Integer intOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.intValue(); + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a number"); + } + + private static Boolean booleanOption(ModelCreationContext context, String key) { + Object value = context.option(key); + if (value == null) { + return null; + } + if (value instanceof Boolean bool) { + return bool; + } + throw new IllegalArgumentException( + "ModelCreationContext option " + key + " must be a boolean"); } } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/OpenAIModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/OpenAIModelProviderTest.java index b80fa8ecf..fcae736c0 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/OpenAIModelProviderTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/OpenAIModelProviderTest.java @@ -15,11 +15,16 @@ */ package io.agentscope.extensions.model.openai; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.transport.ProxyConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -47,6 +52,28 @@ void createRejectsUnsupportedModelIdsBeforeReadingEnvironment() { assertThrows(IllegalArgumentException.class, () -> provider.create(null)); } + @Test + void createUsesModelCreationContext() { + OpenAIModelProvider provider = new OpenAIModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-openai-key") + .baseUrl("https://openai.example.com/v1") + .endpointPath("/v4/chat/completions") + .stream(false) + .component(GenerateOptions.class, GenerateOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 128000) + .option("nativeStructuredOutputWithTools", true) + .build(); + + Model model = provider.create("openai:gpt-4o-mini", context); + + assertTrue(model instanceof OpenAIChatModel); + assertTrue(model.getModelName().equals("gpt-4o-mini")); + assertEquals(128000, model.getContextWindowSize()); + } + @Test void modelRegistryFindsOpenAiProviderFromServiceLoader() { assertTrue(ModelRegistry.canResolve("openai:gpt-4o-mini")); diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/pom.xml new file mode 100644 index 000000000..ba209bade --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + io.agentscope + agentscope-spring-boot-starters + ${revision} + + + agentscope-anthropic-spring-boot-starter + AgentScope Java Anthropic - Spring Boot Starter + Spring Boot starter for AgentScope Java Anthropic model integration + + + false + + + + + io.agentscope + agentscope-spring-boot-starter + + + + io.agentscope + agentscope-extensions-model-anthropic + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java new file mode 100644 index 000000000..2a97625a4 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfiguration.java @@ -0,0 +1,75 @@ +/* + * 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.spring.boot.anthropic; + +import io.agentscope.core.model.Model; +import io.agentscope.extensions.model.anthropic.AnthropicChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Spring Boot auto-configuration for the Anthropic model extension. + */ +@AutoConfiguration(before = AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties(AnthropicProperties.class) +@ConditionalOnClass(AnthropicChatModel.class) +public class AnthropicAutoConfiguration { + + @Bean + @ConditionalOnProperty( + prefix = "agentscope.model", + name = "provider", + havingValue = "anthropic") + @ConditionalOnProperty( + prefix = "agentscope.anthropic", + name = "enabled", + havingValue = "true", + matchIfMissing = true) + @ConditionalOnMissingBean(Model.class) + public AnthropicChatModel anthropicChatModel(AnthropicProperties properties) { + String modelName = trimToNull(properties.getModelName()); + if (modelName == null) { + throw new IllegalStateException( + "agentscope.anthropic.model-name must be configured when Anthropic provider is" + + " selected"); + } + + String apiKey = trimToNull(properties.getApiKey()); + AnthropicChatModel.Builder builder = + AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + properties.isStream()); + + String baseUrl = trimToNull(properties.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + + return builder.build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AnthropicProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java similarity index 89% rename from agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AnthropicProperties.java rename to agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java index 07c3c3d69..f6cbfbafb 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AnthropicProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/java/io/agentscope/spring/boot/anthropic/AnthropicProperties.java @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.agentscope.spring.boot.properties; +package io.agentscope.spring.boot.anthropic; + +import org.springframework.boot.context.properties.ConfigurationProperties; /** * Anthropic provider specific settings. @@ -32,6 +34,7 @@ * stream: true * }

*/ +@ConfigurationProperties(prefix = "agentscope.anthropic") public class AnthropicProperties { /** @@ -40,7 +43,7 @@ public class AnthropicProperties { private boolean enabled = true; /** - * Anthropic API key. + * Anthropic API key. When unset, the Anthropic SDK can load its default key source. */ private String apiKey; diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..e858e9d49 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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. +# +io.agentscope.spring.boot.anthropic.AnthropicAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java new file mode 100644 index 000000000..65f1637d0 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-anthropic-spring-boot-starter/src/test/java/io/agentscope/spring/boot/anthropic/AnthropicAutoConfigurationTest.java @@ -0,0 +1,173 @@ +/* + * 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.spring.boot.anthropic; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.anthropic.AnthropicChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +class AnthropicAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AnthropicAutoConfiguration.class)); + + @Test + void shouldCreateAnthropicModelWhenProviderIsAnthropic() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.api-key=test-anthropic-key", + "agentscope.anthropic.model-name=claude-sonnet-4.5") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(AnthropicChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("claude-sonnet-4.5"); + }); + } + + @Test + void shouldBindSupportedAnthropicProperties() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.api-key=test-anthropic-key", + "agentscope.anthropic.model-name=claude-sonnet-4.5", + "agentscope.anthropic.base-url=https://anthropic.example.com", + "agentscope.anthropic.stream=false") + .run( + context -> { + AnthropicChatModel model = context.getBean(AnthropicChatModel.class); + assertThat(model.getModelName()).isEqualTo("claude-sonnet-4.5"); + }); + } + + @Test + void shouldNotCreateAnthropicModelWhenProviderIsDifferent() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.anthropic.api-key=test-anthropic-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(AnthropicChatModel.class); + }); + } + + @Test + void shouldNotCreateAnthropicModelWhenDisabled() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.enabled=false", + "agentscope.anthropic.api-key=test-anthropic-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(AnthropicChatModel.class); + }); + } + + @Test + void shouldCreateAnthropicModelWhenApiKeyMissing() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.model-name=claude-sonnet-4.5") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(AnthropicChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("claude-sonnet-4.5"); + }); + } + + @Test + void shouldBackOffWhenUserDefinesModelBean() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .withPropertyValues( + "agentscope.model.provider=anthropic", + "agentscope.anthropic.api-key=test-anthropic-key") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).doesNotHaveBean(AnthropicChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("custom-model"); + }); + } + + @Test + void shouldIntegrateWithGenericAgentscopeAutoConfiguration() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + AnthropicAutoConfiguration.class, + AgentscopeAutoConfiguration.class)) + .withPropertyValues( + "agentscope.agent.enabled=true", + "agentscope.model.provider=anthropic", + "agentscope.anthropic.api-key=test-anthropic-key", + "agentscope.anthropic.model-name=claude-sonnet-4.5") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(AnthropicChatModel.class); + assertThat(context).hasSingleBean(ReActAgent.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { + + @Bean + Model customModel() { + return new TestModel(); + } + } + + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return "custom-model"; + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/pom.xml new file mode 100644 index 000000000..203364cef --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + io.agentscope + agentscope-spring-boot-starters + ${revision} + + + agentscope-dashscope-spring-boot-starter + AgentScope Java DashScope - Spring Boot Starter + Spring Boot starter for AgentScope Java DashScope model integration + + + false + + + + + io.agentscope + agentscope-spring-boot-starter + + + + io.agentscope + agentscope-extensions-model-dashscope + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/ConditionalOnDashScopeProvider.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/ConditionalOnDashScopeProvider.java new file mode 100644 index 000000000..e1ebc3a7b --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/ConditionalOnDashScopeProvider.java @@ -0,0 +1,35 @@ +/* + * 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.spring.boot.dashscope; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Conditional; + +/** + * Matches when DashScope is selected as the configured model provider. + * + *

DashScope is also treated as the default provider, so this condition matches when + * {@code agentscope.model.provider} is not configured. + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(DashScopeProviderCondition.class) +@interface ConditionalOnDashScopeProvider {} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfiguration.java new file mode 100644 index 000000000..9168c0cf7 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfiguration.java @@ -0,0 +1,82 @@ +/* + * 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.spring.boot.dashscope; + +import io.agentscope.core.model.Model; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Spring Boot auto-configuration for the DashScope model extension. + */ +@AutoConfiguration(before = AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties(DashScopeProperties.class) +@ConditionalOnClass(DashScopeChatModel.class) +public class DashScopeAutoConfiguration { + + @Bean + @ConditionalOnDashScopeProvider + @ConditionalOnProperty( + prefix = "agentscope.dashscope", + name = "enabled", + havingValue = "true", + matchIfMissing = true) + @ConditionalOnMissingBean(Model.class) + public DashScopeChatModel dashScopeChatModel(DashScopeProperties properties) { + String apiKey = trimToNull(properties.getApiKey()); + if (apiKey == null) { + throw new IllegalStateException( + "agentscope.dashscope.api-key must be configured when DashScope provider is" + + " selected"); + } + + String modelName = trimToNull(properties.getModelName()); + if (modelName == null) { + throw new IllegalStateException( + "agentscope.dashscope.model-name must be configured when DashScope provider is" + + " selected"); + } + + DashScopeChatModel.Builder builder = + DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + properties.isStream()); + + String baseUrl = trimToNull(properties.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + + if (properties.getEnableThinking() != null) { + builder.enableThinking(properties.getEnableThinking()); + } + + return builder.build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/DashscopeProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProperties.java similarity index 91% rename from agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/DashscopeProperties.java rename to agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProperties.java index da82a54fd..95a9ae9c1 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/DashscopeProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProperties.java @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.agentscope.spring.boot.properties; +package io.agentscope.spring.boot.dashscope; + +import org.springframework.boot.context.properties.ConfigurationProperties; /** * DashScope provider specific configuration properties. @@ -31,7 +33,8 @@ * enable-thinking: true * } */ -public class DashscopeProperties { +@ConfigurationProperties(prefix = "agentscope.dashscope") +public class DashScopeProperties { /** * Whether to enable DashScope model auto-configuration. @@ -39,7 +42,7 @@ public class DashscopeProperties { private boolean enabled = true; /** - * DashScope API key used to authenticate requests. + * DashScope API key. */ private String apiKey; diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProviderCondition.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProviderCondition.java new file mode 100644 index 000000000..7102ec6f0 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/dashscope/DashScopeProviderCondition.java @@ -0,0 +1,32 @@ +/* + * 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.spring.boot.dashscope; + +import java.util.Locale; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +final class DashScopeProviderCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + String provider = context.getEnvironment().getProperty("agentscope.model.provider"); + return provider == null + || provider.isBlank() + || "dashscope".equals(provider.trim().toLowerCase(Locale.ROOT)); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..a37f0e5c1 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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. +# +io.agentscope.spring.boot.dashscope.DashScopeAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfigurationTest.java new file mode 100644 index 000000000..985ffc2f5 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-dashscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/dashscope/DashScopeAutoConfigurationTest.java @@ -0,0 +1,186 @@ +/* + * 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.spring.boot.dashscope; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +class DashScopeAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DashScopeAutoConfiguration.class)); + + @Test + void shouldCreateDashScopeModelWhenProviderIsDashscope() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.dashscope.api-key=test-dashscope-key", + "agentscope.dashscope.model-name=qwen-max") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(DashScopeChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("qwen-max"); + }); + } + + @Test + void shouldCreateDashScopeModelWhenProviderIsMissing() { + contextRunner + .withPropertyValues( + "agentscope.dashscope.api-key=test-dashscope-key", + "agentscope.dashscope.model-name=qwen-plus") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(DashScopeChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("qwen-plus"); + }); + } + + @Test + void shouldBindSupportedDashScopeProperties() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.dashscope.api-key=test-dashscope-key", + "agentscope.dashscope.model-name=qwen-max", + "agentscope.dashscope.base-url=https://dashscope.example.com", + "agentscope.dashscope.stream=false", + "agentscope.dashscope.enable-thinking=true") + .run( + context -> { + DashScopeChatModel model = context.getBean(DashScopeChatModel.class); + assertThat(model.getModelName()).isEqualTo("qwen-max"); + }); + } + + @Test + void shouldNotCreateDashScopeModelWhenProviderIsDifferent() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.dashscope.api-key=test-dashscope-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(DashScopeChatModel.class); + }); + } + + @Test + void shouldNotCreateDashScopeModelWhenDisabled() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.dashscope.enabled=false", + "agentscope.dashscope.api-key=test-dashscope-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(DashScopeChatModel.class); + }); + } + + @Test + void shouldFailClearlyWhenApiKeyMissing() { + contextRunner + .withPropertyValues("agentscope.model.provider=dashscope") + .run( + context -> + assertThat(context.getStartupFailure()) + .isNotNull() + .hasMessageContaining( + "agentscope.dashscope.api-key must be configured")); + } + + @Test + void shouldBackOffWhenUserDefinesModelBean() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.dashscope.api-key=test-dashscope-key") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).doesNotHaveBean(DashScopeChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("custom-model"); + }); + } + + @Test + void shouldIntegrateWithGenericAgentscopeAutoConfiguration() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + DashScopeAutoConfiguration.class, + AgentscopeAutoConfiguration.class)) + .withPropertyValues( + "agentscope.agent.enabled=true", + "agentscope.model.provider=dashscope", + "agentscope.dashscope.api-key=test-dashscope-key", + "agentscope.dashscope.model-name=qwen-max") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(DashScopeChatModel.class); + assertThat(context).hasSingleBean(ReActAgent.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { + + @Bean + Model customModel() { + return new TestModel(); + } + } + + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return "custom-model"; + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/pom.xml new file mode 100644 index 000000000..2ddaa27e3 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + io.agentscope + agentscope-spring-boot-starters + ${revision} + + + agentscope-gemini-spring-boot-starter + AgentScope Java Gemini - Spring Boot Starter + Spring Boot starter for AgentScope Java Gemini model integration + + + false + + + + + io.agentscope + agentscope-spring-boot-starter + + + + io.agentscope + agentscope-extensions-model-gemini + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiAutoConfiguration.java new file mode 100644 index 000000000..a35174cd2 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiAutoConfiguration.java @@ -0,0 +1,93 @@ +/* + * 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.spring.boot.gemini; + +import io.agentscope.core.model.Model; +import io.agentscope.extensions.model.gemini.GeminiChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Spring Boot auto-configuration for the Gemini model extension. + */ +@AutoConfiguration(before = AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties(GeminiProperties.class) +@ConditionalOnClass(GeminiChatModel.class) +public class GeminiAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "agentscope.model", name = "provider", havingValue = "gemini") + @ConditionalOnProperty( + prefix = "agentscope.gemini", + name = "enabled", + havingValue = "true", + matchIfMissing = true) + @ConditionalOnMissingBean(Model.class) + public GeminiChatModel geminiChatModel(GeminiProperties properties) { + String modelName = trimToNull(properties.getModelName()); + if (modelName == null) { + throw new IllegalStateException( + "agentscope.gemini.model-name must be configured when Gemini provider is" + + " selected"); + } + + GeminiChatModel.Builder builder = + GeminiChatModel.builder().modelName(modelName).streamEnabled(properties.isStream()); + + // true to use Vertex AI, false/null for Gemini API + if (Boolean.TRUE.equals(properties.getVertexAI())) { + String project = trimToNull(properties.getProject()); + if (project == null) { + throw new IllegalStateException( + "agentscope.gemini.project must be configured when Vertex AI mode" + + " is enabled"); + } + builder.project(project).location(trimToNull(properties.getLocation())); + builder.vertexAI(true); + } else { + String apiKey = trimToNull(properties.getApiKey()); + if (apiKey == null) { + throw new IllegalStateException( + "agentscope.gemini.api-key must be configured when Gemini API mode is" + + " selected"); + } + builder.apiKey(apiKey); + if (Boolean.FALSE.equals(properties.getVertexAI())) { + builder.vertexAI(false); + } + } + + String baseUrl = trimToNull(properties.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + + return builder.build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/GeminiProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiProperties.java similarity index 93% rename from agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/GeminiProperties.java rename to agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiProperties.java index 713514f37..cbc7752cc 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/GeminiProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/java/io/agentscope/spring/boot/gemini/GeminiProperties.java @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.agentscope.spring.boot.properties; +package io.agentscope.spring.boot.gemini; + +import org.springframework.boot.context.properties.ConfigurationProperties; /** * Gemini provider specific settings. @@ -47,6 +49,7 @@ * stream: true * } */ +@ConfigurationProperties(prefix = "agentscope.gemini") public class GeminiProperties { /** @@ -75,7 +78,7 @@ public class GeminiProperties { private boolean stream = true; /** - * Google Cloud project ID (for Vertex AI usage). + * Google Cloud project ID for Vertex AI usage when vertex-ai is true. */ private String project; diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..6b0fe45a6 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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. +# +io.agentscope.spring.boot.gemini.GeminiAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/test/java/io/agentscope/spring/boot/gemini/GeminiAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/test/java/io/agentscope/spring/boot/gemini/GeminiAutoConfigurationTest.java new file mode 100644 index 000000000..8c98c769c --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-gemini-spring-boot-starter/src/test/java/io/agentscope/spring/boot/gemini/GeminiAutoConfigurationTest.java @@ -0,0 +1,204 @@ +/* + * 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.spring.boot.gemini; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.gemini.GeminiChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +class GeminiAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GeminiAutoConfiguration.class)); + + @Test + void shouldCreateGeminiModelWhenProviderIsGemini() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.gemini.api-key=test-gemini-key", + "agentscope.gemini.model-name=gemini-2.0-flash") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(GeminiChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("gemini-2.0-flash"); + }); + } + + @Test + void shouldCreateGeminiModelWithVertexAIProject() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.gemini.project=test-project", + "agentscope.gemini.location=us-central1", + "agentscope.gemini.vertex-ai=true", + "agentscope.gemini.model-name=gemini-2.0-flash") + .run( + context -> + assertThat(context.getStartupFailure()) + .isNotNull() + .hasMessageContaining( + "Failed to get application default credentials")); + } + + @Test + void shouldBindSupportedGeminiProperties() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.gemini.api-key=test-gemini-key", + "agentscope.gemini.model-name=gemini-2.0-flash", + "agentscope.gemini.base-url=https://gemini.example.com", + "agentscope.gemini.stream=false", + "agentscope.gemini.project=test-project", + "agentscope.gemini.location=us-central1", + "agentscope.gemini.vertex-ai=false") + .run( + context -> { + GeminiChatModel model = context.getBean(GeminiChatModel.class); + assertThat(model.getModelName()).isEqualTo("gemini-2.0-flash"); + }); + } + + @Test + void shouldNotCreateGeminiModelWhenProviderIsDifferent() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.gemini.api-key=test-gemini-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(GeminiChatModel.class); + }); + } + + @Test + void shouldNotCreateGeminiModelWhenDisabled() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.gemini.enabled=false", + "agentscope.gemini.api-key=test-gemini-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(GeminiChatModel.class); + }); + } + + @Test + void shouldFailClearlyWhenCredentialMissing() { + contextRunner + .withPropertyValues("agentscope.model.provider=gemini") + .run( + context -> + assertThat(context.getStartupFailure()) + .isNotNull() + .hasMessageContaining( + "agentscope.gemini.api-key must be configured" + + " when Gemini API mode is selected")); + } + + @Test + void shouldFailClearlyWhenVertexAIProjectMissing() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=gemini", "agentscope.gemini.vertex-ai=true") + .run( + context -> + assertThat(context.getStartupFailure()) + .isNotNull() + .hasMessageContaining( + "agentscope.gemini.project must be configured when" + + " Vertex AI mode is enabled")); + } + + @Test + void shouldBackOffWhenUserDefinesModelBean() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .withPropertyValues( + "agentscope.model.provider=gemini", + "agentscope.gemini.api-key=test-gemini-key") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).doesNotHaveBean(GeminiChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("custom-model"); + }); + } + + @Test + void shouldIntegrateWithGenericAgentscopeAutoConfiguration() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + GeminiAutoConfiguration.class, AgentscopeAutoConfiguration.class)) + .withPropertyValues( + "agentscope.agent.enabled=true", + "agentscope.model.provider=gemini", + "agentscope.gemini.api-key=test-gemini-key", + "agentscope.gemini.model-name=gemini-2.0-flash") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(GeminiChatModel.class); + assertThat(context).hasSingleBean(ReActAgent.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { + + @Bean + Model customModel() { + return new TestModel(); + } + } + + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return "custom-model"; + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/pom.xml new file mode 100644 index 000000000..be8dbb202 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + io.agentscope + agentscope-spring-boot-starters + ${revision} + + + agentscope-openai-spring-boot-starter + AgentScope Java OpenAI - Spring Boot Starter + Spring Boot starter for AgentScope Java OpenAI model integration + + + false + + + + + io.agentscope + agentscope-spring-boot-starter + + + + io.agentscope + agentscope-extensions-model-openai + + + + org.springframework.boot + spring-boot-autoconfigure + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIAutoConfiguration.java new file mode 100644 index 000000000..90b64d48a --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIAutoConfiguration.java @@ -0,0 +1,83 @@ +/* + * 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.spring.boot.openai; + +import io.agentscope.core.model.Model; +import io.agentscope.extensions.model.openai.OpenAIChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Spring Boot auto-configuration for the OpenAI model extension. + */ +@AutoConfiguration(before = AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties(OpenAIProperties.class) +@ConditionalOnClass(OpenAIChatModel.class) +public class OpenAIAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "agentscope.model", name = "provider", havingValue = "openai") + @ConditionalOnProperty( + prefix = "agentscope.openai", + name = "enabled", + havingValue = "true", + matchIfMissing = true) + @ConditionalOnMissingBean(Model.class) + public OpenAIChatModel openAIChatModel(OpenAIProperties properties) { + String apiKey = trimToNull(properties.getApiKey()); + if (apiKey == null) { + throw new IllegalStateException( + "agentscope.openai.api-key must be configured when OpenAI provider is" + + " selected"); + } + + String modelName = trimToNull(properties.getModelName()); + if (modelName == null) { + throw new IllegalStateException( + "agentscope.openai.model-name must be configured when OpenAI provider is" + + " selected"); + } + + OpenAIChatModel.Builder builder = + OpenAIChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + properties.isStream()); + + String baseUrl = trimToNull(properties.getBaseUrl()); + if (baseUrl != null) { + builder.baseUrl(baseUrl); + } + + String endpointPath = trimToNull(properties.getEndpointPath()); + if (endpointPath != null) { + builder.endpointPath(endpointPath); + } + + return builder.build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/OpenAIProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIProperties.java similarity index 94% rename from agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/OpenAIProperties.java rename to agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIProperties.java index 08de4cd8b..3fe7c0842 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/OpenAIProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/java/io/agentscope/spring/boot/openai/OpenAIProperties.java @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.agentscope.spring.boot.properties; +package io.agentscope.spring.boot.openai; + +import org.springframework.boot.context.properties.ConfigurationProperties; /** * OpenAI provider specific settings. @@ -33,6 +35,7 @@ * stream: true * } */ +@ConfigurationProperties(prefix = "agentscope.openai") public class OpenAIProperties { /** diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..a4bd357b5 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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. +# +io.agentscope.spring.boot.openai.OpenAIAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/test/java/io/agentscope/spring/boot/openai/OpenAIAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/test/java/io/agentscope/spring/boot/openai/OpenAIAutoConfigurationTest.java new file mode 100644 index 000000000..e80526115 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-openai-spring-boot-starter/src/test/java/io/agentscope/spring/boot/openai/OpenAIAutoConfigurationTest.java @@ -0,0 +1,170 @@ +/* + * 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.spring.boot.openai; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.openai.OpenAIChatModel; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +class OpenAIAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OpenAIAutoConfiguration.class)); + + @Test + void shouldCreateOpenAIModelWhenProviderIsOpenAI() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=openai", + "agentscope.openai.api-key=test-openai-key", + "agentscope.openai.model-name=gpt-4.1-mini") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(OpenAIChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("gpt-4.1-mini"); + }); + } + + @Test + void shouldBindSupportedOpenAIProperties() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=openai", + "agentscope.openai.api-key=test-openai-key", + "agentscope.openai.model-name=gpt-4.1-mini", + "agentscope.openai.base-url=https://example.com/v1", + "agentscope.openai.endpoint-path=/v4/chat/completions", + "agentscope.openai.stream=false") + .run( + context -> { + OpenAIChatModel model = context.getBean(OpenAIChatModel.class); + assertThat(model.getModelName()).isEqualTo("gpt-4.1-mini"); + }); + } + + @Test + void shouldNotCreateOpenAIModelWhenProviderIsDifferent() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=dashscope", + "agentscope.openai.api-key=test-openai-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(OpenAIChatModel.class); + }); + } + + @Test + void shouldNotCreateOpenAIModelWhenDisabled() { + contextRunner + .withPropertyValues( + "agentscope.model.provider=openai", + "agentscope.openai.enabled=false", + "agentscope.openai.api-key=test-openai-key") + .run( + context -> { + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(OpenAIChatModel.class); + }); + } + + @Test + void shouldFailClearlyWhenApiKeyMissing() { + contextRunner + .withPropertyValues("agentscope.model.provider=openai") + .run( + context -> + assertThat(context.getStartupFailure()) + .isNotNull() + .hasMessageContaining( + "agentscope.openai.api-key must be configured")); + } + + @Test + void shouldBackOffWhenUserDefinesModelBean() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .withPropertyValues( + "agentscope.model.provider=openai", + "agentscope.openai.api-key=test-openai-key") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).doesNotHaveBean(OpenAIChatModel.class); + assertThat(context.getBean(Model.class).getModelName()) + .isEqualTo("custom-model"); + }); + } + + @Test + void shouldIntegrateWithGenericAgentscopeAutoConfiguration() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + OpenAIAutoConfiguration.class, AgentscopeAutoConfiguration.class)) + .withPropertyValues( + "agentscope.agent.enabled=true", + "agentscope.model.provider=openai", + "agentscope.openai.api-key=test-openai-key", + "agentscope.openai.model-name=gpt-4.1-mini") + .run( + context -> { + assertThat(context).hasSingleBean(Model.class); + assertThat(context).hasSingleBean(OpenAIChatModel.class); + assertThat(context).hasSingleBean(ReActAgent.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { + + @Bean + Model customModel() { + return new TestModel(); + } + } + + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return "custom-model"; + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/pom.xml index 08e905af5..35cac2299 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/pom.xml +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/pom.xml @@ -70,31 +70,6 @@ spring-boot-starter-test test - - io.agentscope - agentscope-extensions-model-openai - test - - - - - - - - io.agentscope - agentscope-extensions-model-gemini - test - - - io.agentscope - agentscope-extensions-model-anthropic - test - - - io.agentscope - agentscope-extensions-model-dashscope - test - diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java index f6244c447..b509d42b8 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/AgentscopeAutoConfiguration.java @@ -20,11 +20,11 @@ import io.agentscope.core.memory.Memory; import io.agentscope.core.model.Model; import io.agentscope.core.tool.Toolkit; -import io.agentscope.spring.boot.model.ModelProviderType; import io.agentscope.spring.boot.properties.AgentProperties; import io.agentscope.spring.boot.properties.AgentscopeProperties; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -33,94 +33,24 @@ import org.springframework.context.annotation.Scope; /** - * Spring Boot auto-configuration that exposes default Model, Memory, Toolkit - * and ReActAgent beans - * for AgentScope. + * Spring Boot auto-configuration that exposes default Memory, Toolkit and ReActAgent beans for + * AgentScope. * - *

- * Basic configuration with DashScope (default provider): + *

Model beans are provided by provider-specific starters such as + * {@code agentscope-dashscope-spring-boot-starter}, {@code agentscope-openai-spring-boot-starter}, + * {@code agentscope-gemini-spring-boot-starter}, {@code agentscope-anthropic-spring-boot-starter}, + * or by user-defined {@link Model} beans. + * + *

Basic configuration: * *

{@code
  * agentscope:
- *   # Select model provider (defaults to dashscope when omitted)
- *   model:
- *     provider: dashscope
- *
- *   dashscope:
- *     enabled: true
- *     api-key: ${DASHSCOPE_API_KEY}
- *     model-name: qwen-plus
- *     stream: true
- *     enable-thinking: true
- *
  *   agent:
  *     enabled: true
  *     name: "Assistant"
  *     sys-prompt: "You are a helpful AI assistant."
  *     max-iters: 10
  * }
- * - *

- * Using OpenAI as provider: - * - *

{@code
- * agentscope:
- *   model:
- *     provider: openai
- *
- *   openai:
- *     enabled: true
- *     api-key: ${OPENAI_API_KEY}
- *     model-name: gpt-4.1-mini
- *     stream: true
- * }
- * - *

- * Using Gemini as provider (direct API): - * - *

{@code
- * agentscope:
- *   model:
- *     provider: gemini
- *
- *   gemini:
- *     enabled: true
- *     api-key: ${GEMINI_API_KEY}
- *     model-name: gemini-2.0-flash
- *     stream: true
- * }
- * - *

- * Using Gemini via Vertex AI: - * - *

{@code
- * agentscope:
- *   model:
- *     provider: gemini
- *
- *   gemini:
- *     enabled: true
- *     project: your-gcp-project-id
- *     location: us-central1
- *     model-name: gemini-2.0-flash
- *     vertex-ai: true
- *     stream: true
- * }
- * - *

- * Using Anthropic as provider: - * - *

{@code
- * agentscope:
- *   model:
- *     provider: anthropic
- *
- *   anthropic:
- *     enabled: true
- *     api-key: ${ANTHROPIC_API_KEY}
- *     model-name: claude-sonnet-4.5
- *     stream: true
- * }
*/ @AutoConfiguration @EnableConfigurationProperties(AgentscopeProperties.class) @@ -163,22 +93,6 @@ public Toolkit agentscopeToolkit() { return new Toolkit(); } - /** - * Default Model implementation. - * - *

- * If DashScopeChatModel is on the classpath and dashscope auto-configuration is - * enabled, this - * method creates a DashScopeChatModel based on {@link ModelProviderType} - * settings. - */ - @Bean - @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") - @ConditionalOnMissingBean(Model.class) - public Model agentscopeModel(AgentscopeProperties properties) { - return ModelProviderType.fromProperties(properties).createModel(properties); - } - /** * Default ReActAgent that wires together the configured Model, Memory and * Toolkit beans using @@ -188,6 +102,7 @@ public Model agentscopeModel(AgentscopeProperties properties) { */ @Bean @ConditionalOnMissingBean + @ConditionalOnBean(Model.class) @ConditionalOnProperty(prefix = "agentscope.agent", name = "enabled", havingValue = "true") public ReActAgent agentscopeReActAgent( Model model, Memory memory, Toolkit toolkit, AgentscopeProperties properties) { diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/model/ModelProviderType.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/model/ModelProviderType.java deleted file mode 100644 index cd8550b08..000000000 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/model/ModelProviderType.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - * 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.spring.boot.model; - -import io.agentscope.core.model.Model; -import io.agentscope.spring.boot.properties.AgentscopeProperties; -import io.agentscope.spring.boot.properties.AnthropicProperties; -import io.agentscope.spring.boot.properties.DashscopeProperties; -import io.agentscope.spring.boot.properties.GeminiProperties; -import io.agentscope.spring.boot.properties.ModelProperties; -import io.agentscope.spring.boot.properties.OpenAIProperties; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.Locale; - -/** - * Enum-based strategy for creating concrete {@link Model} instances from configuration. - */ -public enum ModelProviderType { - DASHSCOPE("dashscope") { - @Override - public Model createModel(AgentscopeProperties properties) { - DashscopeProperties dashscope = properties.getDashscope(); - if (!dashscope.isEnabled()) { - throw new IllegalStateException( - "DashScope model auto-configuration is disabled but selected as provider"); - } - if (dashscope.getApiKey() == null || dashscope.getApiKey().isEmpty()) { - throw new IllegalStateException( - "agentscope.dashscope.api-key must be configured when Dashscope" - + " auto-configuration is enabled"); - } - - return createDashScopeModel(dashscope); - } - }, - OPENAI("openai") { - @Override - public Model createModel(AgentscopeProperties properties) { - OpenAIProperties openai = properties.getOpenai(); - if (!openai.isEnabled()) { - throw new IllegalStateException( - "OpenAI model auto-configuration is disabled but selected as provider"); - } - if (openai.getApiKey() == null || openai.getApiKey().isEmpty()) { - throw new IllegalStateException( - "agentscope.openai.api-key must be configured when OpenAI provider is" - + " selected"); - } - - return createOpenAiModel(openai); - } - }, - GEMINI("gemini") { - @Override - public Model createModel(AgentscopeProperties properties) { - GeminiProperties gemini = properties.getGemini(); - if (!gemini.isEnabled()) { - throw new IllegalStateException( - "Gemini model auto-configuration is disabled but selected as provider"); - } - if ((gemini.getApiKey() == null || gemini.getApiKey().isEmpty()) - && (gemini.getProject() == null || gemini.getProject().isEmpty())) { - throw new IllegalStateException( - "Either agentscope.gemini.api-key or agentscope.gemini.project must be" - + " configured when Gemini provider is selected"); - } - - return createGeminiModel(gemini); - } - }, - ANTHROPIC("anthropic") { - @Override - public Model createModel(AgentscopeProperties properties) { - AnthropicProperties anthropic = properties.getAnthropic(); - if (!anthropic.isEnabled()) { - throw new IllegalStateException( - "Anthropic model auto-configuration is disabled but selected as provider"); - } - if (anthropic.getApiKey() == null || anthropic.getApiKey().isEmpty()) { - throw new IllegalStateException( - "agentscope.anthropic.api-key must be configured when Anthropic provider is" - + " selected"); - } - - return createAnthropicModel(anthropic); - } - }; - - private final String id; - - ModelProviderType(String id) { - this.id = id; - } - - /** - * Create a concrete {@link Model} instance using the given properties. - */ - public abstract Model createModel(AgentscopeProperties properties); - - /** - * Resolve provider from root properties. Defaults to {@link #DASHSCOPE} when provider is not - * configured. - * - * @param properties root configuration properties - * @return resolved provider enum - */ - public static ModelProviderType fromProperties(AgentscopeProperties properties) { - ModelProperties modelProps = properties.getModel(); - String provider = modelProps != null ? modelProps.getProvider() : null; - String normalized = - provider == null || provider.isBlank() - ? DASHSCOPE.id - : provider.trim().toLowerCase(Locale.ROOT); - - for (ModelProviderType type : values()) { - if (type.id.equals(normalized)) { - return type; - } - } - throw new IllegalStateException("Unsupported agentscope.model.provider: " + normalized); - } - - private static Model createDashScopeModel(DashscopeProperties dashscope) { - try { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - if (classLoader == null) { - classLoader = ModelProviderType.class.getClassLoader(); - } - Class factoryClass = - Class.forName( - "io.agentscope.extensions.model.dashscope.DashScopeChatModelFactory", - true, - classLoader); - Method create = - factoryClass.getMethod( - "create", - String.class, - String.class, - boolean.class, - String.class, - Boolean.class); - return (Model) - create.invoke( - null, - dashscope.getApiKey(), - dashscope.getModelName(), - dashscope.isStream(), - dashscope.getBaseUrl(), - dashscope.getEnableThinking()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "DashScope provider requires agentscope-extensions-model-dashscope on the" - + " classpath", - e); - } catch (NoSuchMethodException | IllegalAccessException e) { - throw new IllegalStateException( - "DashScope extension is incompatible with this Spring Boot starter", e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new IllegalStateException("Failed to create DashScope model", cause); - } - } - - private static Model createOpenAiModel(OpenAIProperties openai) { - try { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - if (classLoader == null) { - classLoader = ModelProviderType.class.getClassLoader(); - } - Class factoryClass = - Class.forName( - "io.agentscope.extensions.model.openai.OpenAIChatModelFactory", - true, - classLoader); - Method create = - factoryClass.getMethod( - "create", - String.class, - String.class, - boolean.class, - String.class, - String.class); - return (Model) - create.invoke( - null, - openai.getApiKey(), - openai.getModelName(), - openai.isStream(), - openai.getBaseUrl(), - openai.getEndpointPath()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "OpenAI provider requires agentscope-extensions-model-openai on the classpath", - e); - } catch (NoSuchMethodException | IllegalAccessException e) { - throw new IllegalStateException( - "OpenAI extension is incompatible with this Spring Boot starter", e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new IllegalStateException("Failed to create OpenAI model", cause); - } - } - - private static Model createGeminiModel(GeminiProperties gemini) { - try { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - if (classLoader == null) { - classLoader = ModelProviderType.class.getClassLoader(); - } - Class factoryClass = - Class.forName( - "io.agentscope.extensions.model.gemini.GeminiChatModelFactory", - true, - classLoader); - Method create = - factoryClass.getMethod( - "create", - String.class, - String.class, - boolean.class, - String.class, - String.class, - String.class, - Boolean.class); - return (Model) - create.invoke( - null, - gemini.getApiKey(), - gemini.getModelName(), - gemini.isStream(), - gemini.getBaseUrl(), - gemini.getProject(), - gemini.getLocation(), - gemini.getVertexAI()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Gemini provider requires agentscope-extensions-model-gemini on the classpath", - e); - } catch (NoSuchMethodException | IllegalAccessException e) { - throw new IllegalStateException( - "Gemini extension is incompatible with this Spring Boot starter", e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new IllegalStateException("Failed to create Gemini model", cause); - } - } - - private static Model createAnthropicModel(AnthropicProperties anthropic) { - try { - ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); - if (classLoader == null) { - classLoader = ModelProviderType.class.getClassLoader(); - } - Class factoryClass = - Class.forName( - "io.agentscope.extensions.model.anthropic.AnthropicChatModelFactory", - true, - classLoader); - Method create = - factoryClass.getMethod( - "create", String.class, String.class, boolean.class, String.class); - return (Model) - create.invoke( - null, - anthropic.getApiKey(), - anthropic.getModelName(), - anthropic.isStream(), - anthropic.getBaseUrl()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Anthropic provider requires agentscope-extensions-model-anthropic on the" - + " classpath", - e); - } catch (NoSuchMethodException | IllegalAccessException e) { - throw new IllegalStateException( - "Anthropic extension is incompatible with this Spring Boot starter", e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new IllegalStateException("Failed to create Anthropic model", cause); - } - } -} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java index 572076b2b..24fd7158c 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/main/java/io/agentscope/spring/boot/properties/AgentscopeProperties.java @@ -24,11 +24,7 @@ * *

    *
  • {@link AgentProperties} under {@code agentscope.agent}
  • - *
  • {@link DashscopeProperties} under {@code agentscope.dashscope}
  • *
  • {@link ModelProperties} under {@code agentscope.model}
  • - *
  • {@link OpenAIProperties} under {@code agentscope.openai}
  • - *
  • {@link GeminiProperties} under {@code agentscope.gemini}
  • - *
  • {@link AnthropicProperties} under {@code agentscope.anthropic}
  • *
*/ @ConfigurationProperties(prefix = "agentscope") @@ -36,37 +32,13 @@ public class AgentscopeProperties { private final AgentProperties agent = new AgentProperties(); - private final DashscopeProperties dashscope = new DashscopeProperties(); - private final ModelProperties model = new ModelProperties(); - private final OpenAIProperties openai = new OpenAIProperties(); - - private final GeminiProperties gemini = new GeminiProperties(); - - private final AnthropicProperties anthropic = new AnthropicProperties(); - public AgentProperties getAgent() { return agent; } - public DashscopeProperties getDashscope() { - return dashscope; - } - public ModelProperties getModel() { return model; } - - public OpenAIProperties getOpenai() { - return openai; - } - - public GeminiProperties getGemini() { - return gemini; - } - - public AnthropicProperties getAnthropic() { - return anthropic; - } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java index f65141c71..cb58146b4 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-spring-boot-starter/src/test/java/io/agentscope/spring/boot/AgentscopeAutoConfigurationTest.java @@ -16,263 +16,133 @@ package io.agentscope.spring.boot; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import io.agentscope.core.ReActAgent; +import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; import io.agentscope.core.tool.Toolkit; -import io.agentscope.spring.boot.model.ModelProviderType; -import io.agentscope.spring.boot.properties.AgentscopeProperties; +import java.util.List; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; /** * Tests for {@link AgentscopeAutoConfiguration}. - * - *

These tests verify that the auto-configuration creates the expected beans under different - * property setups. */ -@ExtendWith(OutputCaptureExtension.class) class AgentscopeAutoConfigurationTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.dashscope.api-key=test-api-key"); + .withPropertyValues("agentscope.agent.enabled=true"); @Test - void shouldCreateDefaultBeansWhenEnabled() { + void shouldCreateMemoryAndToolkitWhenEnabled() { contextRunner.run( context -> { assertThat(context).hasSingleBean(Memory.class); assertThat(context).hasSingleBean(Toolkit.class); - assertThat(context).hasSingleBean(Model.class); - assertThat(context).hasSingleBean(ReActAgent.class); + assertThat(context).doesNotHaveBean(Model.class); + assertThat(context).doesNotHaveBean(ReActAgent.class); }); } @Test - void shouldNotCreateReActAgentWhenDisabled() { + void shouldCreateReActAgentWhenModelBeanExists() { contextRunner - .withPropertyValues("agentscope.agent.enabled=false") - .run( - context -> { - assertThat(context).doesNotHaveBean(ReActAgent.class); - assertThat(context).doesNotHaveBean(Memory.class); - assertThat(context).doesNotHaveBean(Toolkit.class); - assertThat(context).doesNotHaveBean(Model.class); - }); - } - - @Test - void shouldFailWhenApiKeyMissing() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues("agentscope.agent.enabled=true") - .run( - context -> - assertThat(context.getStartupFailure()) - .isNotNull() - .hasMessageContaining( - "agentscope.dashscope.api-key must be configured")); - } - - @Test - void shouldCreateDashScopeModelWhenProviderIsDashscope() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.model.provider=dashscope", - "agentscope.dashscope.api-key=test-dashscope-key", - "agentscope.dashscope.model-name=qwen-max") + .withUserConfiguration(CustomModelConfiguration.class) .run( context -> { + assertThat(context).hasSingleBean(Memory.class); + assertThat(context).hasSingleBean(Toolkit.class); assertThat(context).hasSingleBean(Model.class); - assertThat(context.getBean(Model.class).getModelName()) - .isEqualTo("qwen-max"); + assertThat(context).hasSingleBean(ReActAgent.class); }); } @Test - void shouldFailClearlyWhenDashScopeExtensionIsMissing() { - AgentscopeProperties properties = new AgentscopeProperties(); - properties.getModel().setProvider("dashscope"); - properties.getDashscope().setApiKey("test-dashscope-key"); - properties.getDashscope().setModelName("qwen-max"); - - ClassLoader original = Thread.currentThread().getContextClassLoader(); - Thread.currentThread() - .setContextClassLoader( - new HidingClassLoader( - original, - "io.agentscope.extensions.model.dashscope" - + ".DashScopeChatModelFactory")); - try { - assertThatThrownBy(() -> ModelProviderType.DASHSCOPE.createModel(properties)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("agentscope-extensions-model-dashscope"); - } finally { - Thread.currentThread().setContextClassLoader(original); - } - } - - @Test - void shouldCreateOpenAIModelWhenProviderIsOpenAI() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.model.provider=openai", - "agentscope.openai.api-key=test-openai-key", - "agentscope.openai.model-name=gpt-4.1-mini") + void shouldNotCreateBeansWhenAgentIsDisabled() { + contextRunner + .withUserConfiguration(CustomModelConfiguration.class) + .withPropertyValues("agentscope.agent.enabled=false") .run( context -> { assertThat(context).hasSingleBean(Model.class); - assertThat(context.getBean(Model.class).getModelName()) - .isEqualTo("gpt-4.1-mini"); + assertThat(context).doesNotHaveBean(ReActAgent.class); + assertThat(context).doesNotHaveBean(Memory.class); + assertThat(context).doesNotHaveBean(Toolkit.class); }); } @Test - void shouldCreateOpenAIModelWithCustomEndpointPath() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.model.provider=openai", - "agentscope.openai.api-key=test-openai-key", - "agentscope.openai.model-name=gpt-4.1-mini", - "agentscope.openai.endpoint-path=/v4/chat/completions") + void shouldBackOffWhenUserDefinesMemoryToolkitAndAgentBeans() { + contextRunner + .withUserConfiguration(CustomAgentConfiguration.class) .run( context -> { - assertThat(context).hasSingleBean(Model.class); - assertThat(context.getBean(Model.class).getModelName()) - .isEqualTo("gpt-4.1-mini"); + assertThat(context).hasSingleBean(Memory.class); + assertThat(context).hasSingleBean(Toolkit.class); + assertThat(context).hasSingleBean(ReActAgent.class); + assertThat(context.getBean("customMemory")) + .isSameAs(context.getBean(Memory.class)); + assertThat(context.getBean("customToolkit")) + .isSameAs(context.getBean(Toolkit.class)); + assertThat(context.getBean("customAgent")) + .isSameAs(context.getBean(ReActAgent.class)); }); } - @Test - void shouldFailClearlyWhenOpenAIExtensionIsMissing() { - AgentscopeProperties properties = new AgentscopeProperties(); - properties.getModel().setProvider("open-ai"); - properties.getOpenai().setApiKey("test-openai-key"); - properties.getOpenai().setModelName("gpt-4.1-mini"); + @Configuration(proxyBeanMethods = false) + static class CustomModelConfiguration { - ClassLoader original = Thread.currentThread().getContextClassLoader(); - Thread.currentThread() - .setContextClassLoader( - new HidingClassLoader( - original, - "io.agentscope.extensions.model.openai.OpenAIChatModelFactory")); - try { - assertThatThrownBy(() -> ModelProviderType.OPENAI.createModel(properties)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("agentscope-extensions-model-openai"); - } finally { - Thread.currentThread().setContextClassLoader(original); + @Bean + Model customModel() { + return new TestModel(); } } - @Test - void shouldCreateGeminiModelWhenProviderIsGemini() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.model.provider=gemini", - "agentscope.gemini.api-key=test-gemini-key", - "agentscope.gemini.model-name=gemini-2.0-flash") - .run( - context -> { - assertThat(context).hasSingleBean(Model.class); - assertThat(context.getBean(Model.class).getModelName()) - .isEqualTo("gemini-2.0-flash"); - }); - } - - @Test - void shouldFailClearlyWhenGeminiExtensionIsMissing() { - AgentscopeProperties properties = new AgentscopeProperties(); - properties.getModel().setProvider("gemini"); - properties.getGemini().setApiKey("test-gemini-key"); - properties.getGemini().setModelName("gemini-2.0-flash"); + @Configuration(proxyBeanMethods = false) + static class CustomAgentConfiguration { - ClassLoader original = Thread.currentThread().getContextClassLoader(); - Thread.currentThread() - .setContextClassLoader( - new HidingClassLoader( - original, - "io.agentscope.extensions.model.gemini.GeminiChatModelFactory")); - try { - assertThatThrownBy(() -> ModelProviderType.GEMINI.createModel(properties)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("agentscope-extensions-model-gemini"); - } finally { - Thread.currentThread().setContextClassLoader(original); + @Bean + Model customModel() { + return new TestModel(); } - } - @Test - void shouldCreateAnthropicModelWhenProviderIsAnthropic() { - new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(AgentscopeAutoConfiguration.class)) - .withPropertyValues( - "agentscope.agent.enabled=true", - "agentscope.model.provider=anthropic", - "agentscope.anthropic.api-key=test-anthropic-key", - "agentscope.anthropic.model-name=claude-sonnet-4.5") - .run( - context -> { - assertThat(context).hasSingleBean(Model.class); - assertThat(context.getBean(Model.class).getModelName()) - .isEqualTo("claude-sonnet-4.5"); - }); - } + @Bean + Memory customMemory() { + return new InMemoryMemory(); + } - @Test - void shouldFailClearlyWhenAnthropicExtensionIsMissing() { - AgentscopeProperties properties = new AgentscopeProperties(); - properties.getModel().setProvider("anthropic"); - properties.getAnthropic().setApiKey("test-anthropic-key"); - properties.getAnthropic().setModelName("claude-sonnet-4.5"); + @Bean + Toolkit customToolkit() { + return new Toolkit(); + } - ClassLoader original = Thread.currentThread().getContextClassLoader(); - Thread.currentThread() - .setContextClassLoader( - new HidingClassLoader( - original, - "io.agentscope.extensions.model.anthropic" - + ".AnthropicChatModelFactory")); - try { - assertThatThrownBy(() -> ModelProviderType.ANTHROPIC.createModel(properties)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("agentscope-extensions-model-anthropic"); - } finally { - Thread.currentThread().setContextClassLoader(original); + @Bean + ReActAgent customAgent(Model model, Toolkit toolkit) { + return ReActAgent.builder().name("customAgent").model(model).toolkit(toolkit).build(); } } - private static final class HidingClassLoader extends ClassLoader { - private final String hiddenClassName; - - private HidingClassLoader(ClassLoader parent, String hiddenClassName) { - super(parent); - this.hiddenClassName = hiddenClassName; + private static final class TestModel implements Model { + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); } @Override - protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { - if (hiddenClassName.equals(name)) { - throw new ClassNotFoundException(name); - } - return super.loadClass(name, resolve); + public String getModelName() { + return "custom-model"; } } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/pom.xml index 598c45bf5..91cd1daef 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/pom.xml +++ b/agentscope-extensions/agentscope-spring-boot-starters/pom.xml @@ -38,6 +38,10 @@ agentscope-spring-boot-starter + agentscope-openai-spring-boot-starter + agentscope-dashscope-spring-boot-starter + agentscope-gemini-spring-boot-starter + agentscope-anthropic-spring-boot-starter agentscope-a2a-spring-boot-starter agentscope-agui-spring-boot-starter agentscope-chat-completions-web-starter diff --git a/docs/v1/en/docs/multi-agent/handoffs.md b/docs/v1/en/docs/multi-agent/handoffs.md index c4d41bbf8..fcdb242a2 100644 --- a/docs/v1/en/docs/multi-agent/handoffs.md +++ b/docs/v1/en/docs/multi-agent/handoffs.md @@ -108,7 +108,7 @@ Create a sales and a support agent as `AgentScopeAgent`, each with its own ReAct import com.alibaba.cloud.ai.agent.agentscope.AgentScopeAgent; import io.agentscope.core.ReActAgent; import io.agentscope.core.memory.InMemoryMemory; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; // Sales agent: has transfer_to_support diff --git a/docs/v1/en/docs/multi-agent/multiagent-debate.md b/docs/v1/en/docs/multi-agent/multiagent-debate.md index 978d3a468..5507937bd 100644 --- a/docs/v1/en/docs/multi-agent/multiagent-debate.md +++ b/docs/v1/en/docs/multi-agent/multiagent-debate.md @@ -37,9 +37,9 @@ This pattern is inspired by research showing that multi-agent debate can improve ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; // Define the debate topic String topic = """ @@ -207,12 +207,12 @@ Here's a complete, runnable example: package io.agentscope.examples; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.pipeline.MsgHub; public class MultiAgentDebateExample { diff --git a/docs/v1/en/docs/multi-agent/pipeline.md b/docs/v1/en/docs/multi-agent/pipeline.md index 65e7200ef..ab810a4c7 100644 --- a/docs/v1/en/docs/multi-agent/pipeline.md +++ b/docs/v1/en/docs/multi-agent/pipeline.md @@ -17,7 +17,7 @@ The example uses a single `Model` bean (DashScopeChatModel) shared by all pipeli ```java package com.alibaba.cloud.ai.examples.multiagents.pipeline; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.Model; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/docs/v1/en/docs/quickstart/agent.md b/docs/v1/en/docs/quickstart/agent.md index 5c7ec32bb..56552cd3b 100644 --- a/docs/v1/en/docs/quickstart/agent.md +++ b/docs/v1/en/docs/quickstart/agent.md @@ -46,7 +46,7 @@ Using DashScope API as an example, we create an agent as follows: ```java import io.agentscope.core.ReActAgent; import io.agentscope.core.message.Msg; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; diff --git a/docs/v1/en/docs/task/agent-as-tool.md b/docs/v1/en/docs/task/agent-as-tool.md index dea6f5a43..637e86305 100644 --- a/docs/v1/en/docs/task/agent-as-tool.md +++ b/docs/v1/en/docs/task/agent-as-tool.md @@ -37,7 +37,7 @@ Parent Agent ──call──→ SubAgentTool ──create──→ Sub-agent ```java import io.agentscope.core.ReActAgent; import io.agentscope.core.tool.Toolkit; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; // Create model DashScopeChatModel model = DashScopeChatModel.builder() diff --git a/docs/v1/en/docs/task/agent-config.md b/docs/v1/en/docs/task/agent-config.md index 19b4239bc..4dea4da43 100644 --- a/docs/v1/en/docs/task/agent-config.md +++ b/docs/v1/en/docs/task/agent-config.md @@ -479,12 +479,12 @@ The following example demonstrates the complete usage of all core configuration package io.agentscope.tutorial; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.StructuredOutputReminder; import io.agentscope.core.model.ExecutionConfig; diff --git a/docs/v1/en/docs/task/msghub.md b/docs/v1/en/docs/task/msghub.md index e5facd87d..c980741ab 100644 --- a/docs/v1/en/docs/task/msghub.md +++ b/docs/v1/en/docs/task/msghub.md @@ -45,12 +45,12 @@ try (MsgHub hub = MsgHub.builder() ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.pipeline.MsgHub; // Create model with MultiAgentFormatter (important!) diff --git a/docs/v1/en/docs/task/multimodal.md b/docs/v1/en/docs/task/multimodal.md index 93431979b..bcbabb20a 100644 --- a/docs/v1/en/docs/task/multimodal.md +++ b/docs/v1/en/docs/task/multimodal.md @@ -114,8 +114,8 @@ Msg multiImageMsg = Msg.builder() ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; ReActAgent agent = ReActAgent.builder() .name("VisionAssistant") @@ -145,10 +145,10 @@ System.out.println(response.getTextContent()); package io.agentscope.examples; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.*; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; import java.util.List; diff --git a/docs/v1/en/docs/task/observability.md b/docs/v1/en/docs/task/observability.md index 607fb8aac..cfe4283e0 100644 --- a/docs/v1/en/docs/task/observability.md +++ b/docs/v1/en/docs/task/observability.md @@ -125,7 +125,7 @@ package io.agentscope.examples; import io.agentscope.core.ReActAgent; import io.agentscope.core.message.Msg; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.studio.StudioManager; import io.agentscope.core.studio.StudioMessageHook; import io.agentscope.core.studio.StudioUserAgent; diff --git a/docs/v1/zh/docs/multi-agent/handoffs.md b/docs/v1/zh/docs/multi-agent/handoffs.md index b298522ba..74eb5f957 100644 --- a/docs/v1/zh/docs/multi-agent/handoffs.md +++ b/docs/v1/zh/docs/multi-agent/handoffs.md @@ -108,7 +108,7 @@ public String transferToSales( import com.alibaba.cloud.ai.agent.agentscope.AgentScopeAgent; import io.agentscope.core.ReActAgent; import io.agentscope.core.memory.InMemoryMemory; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; // 销售智能体:具备 transfer_to_support diff --git a/docs/v1/zh/docs/multi-agent/multiagent-debate.md b/docs/v1/zh/docs/multi-agent/multiagent-debate.md index c01b797f3..82da42978 100644 --- a/docs/v1/zh/docs/multi-agent/multiagent-debate.md +++ b/docs/v1/zh/docs/multi-agent/multiagent-debate.md @@ -37,9 +37,9 @@ ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; // 定义辩论主题 String topic = """ @@ -202,12 +202,12 @@ public void runDebate() { package io.agentscope.examples; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.pipeline.MsgHub; public class MultiAgentDebateExample { diff --git a/docs/v1/zh/docs/multi-agent/pipeline.md b/docs/v1/zh/docs/multi-agent/pipeline.md index 802ead68f..77b072004 100644 --- a/docs/v1/zh/docs/multi-agent/pipeline.md +++ b/docs/v1/zh/docs/multi-agent/pipeline.md @@ -15,7 +15,7 @@ ```java package com.alibaba.cloud.ai.examples.multiagents.pipeline; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.Model; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/docs/v1/zh/docs/quickstart/agent.md b/docs/v1/zh/docs/quickstart/agent.md index 684a14530..ceb1f550b 100644 --- a/docs/v1/zh/docs/quickstart/agent.md +++ b/docs/v1/zh/docs/quickstart/agent.md @@ -46,7 +46,7 @@ AgentScope 提供了开箱即用的 ReAct 智能体 `ReActAgent` 供开发者使 ```java import io.agentscope.core.ReActAgent; import io.agentscope.core.message.Msg; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; diff --git a/docs/v1/zh/docs/task/agent-as-tool.md b/docs/v1/zh/docs/task/agent-as-tool.md index 6ea8362a4..27bcaf0ef 100644 --- a/docs/v1/zh/docs/task/agent-as-tool.md +++ b/docs/v1/zh/docs/task/agent-as-tool.md @@ -37,7 +37,7 @@ Agent as Tool 允许将一个智能体注册为工具,供其他智能体调用 ```java import io.agentscope.core.ReActAgent; import io.agentscope.core.tool.Toolkit; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; // 创建模型 DashScopeChatModel model = DashScopeChatModel.builder() diff --git a/docs/v1/zh/docs/task/agent-config.md b/docs/v1/zh/docs/task/agent-config.md index 0e19f316a..bc4a02a51 100644 --- a/docs/v1/zh/docs/task/agent-config.md +++ b/docs/v1/zh/docs/task/agent-config.md @@ -479,12 +479,12 @@ SkillBox skillBox = new SkillBox(new Toolkit()); package io.agentscope.tutorial; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.StructuredOutputReminder; import io.agentscope.core.model.ExecutionConfig; diff --git a/docs/v1/zh/docs/task/msghub.md b/docs/v1/zh/docs/task/msghub.md index 7f67c4ba4..22fe356fb 100644 --- a/docs/v1/zh/docs/task/msghub.md +++ b/docs/v1/zh/docs/task/msghub.md @@ -45,12 +45,12 @@ try (MsgHub hub = MsgHub.builder() ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.pipeline.MsgHub; // 创建模型,使用 MultiAgentFormatter diff --git a/docs/v1/zh/docs/task/multimodal.md b/docs/v1/zh/docs/task/multimodal.md index ac41e6c1d..f4e5ca3fd 100644 --- a/docs/v1/zh/docs/task/multimodal.md +++ b/docs/v1/zh/docs/task/multimodal.md @@ -114,8 +114,8 @@ Msg multiImageMsg = Msg.builder() ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; ReActAgent agent = ReActAgent.builder() .name("VisionAssistant") @@ -145,10 +145,10 @@ System.out.println(response.getTextContent()); package io.agentscope.examples; import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import io.agentscope.core.memory.InMemoryMemory; import io.agentscope.core.message.*; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; import java.util.List; diff --git a/docs/v1/zh/docs/task/observability.md b/docs/v1/zh/docs/task/observability.md index 08a555a92..e3ad40bbe 100644 --- a/docs/v1/zh/docs/task/observability.md +++ b/docs/v1/zh/docs/task/observability.md @@ -123,7 +123,7 @@ package io.agentscope.examples; import io.agentscope.core.ReActAgent; import io.agentscope.core.message.Msg; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.studio.StudioManager; import io.agentscope.core.studio.StudioMessageHook; import io.agentscope.core.studio.StudioUserAgent; diff --git a/docs/v2/en/docs/building-blocks/agent.md b/docs/v2/en/docs/building-blocks/agent.md index 69944f334..34f7bbf44 100644 --- a/docs/v2/en/docs/building-blocks/agent.md +++ b/docs/v2/en/docs/building-blocks/agent.md @@ -82,8 +82,8 @@ ReActAgent agent = :::{tab-item} Explicit Model builder ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; ReActAgent agent = diff --git a/docs/v2/en/docs/building-blocks/model.md b/docs/v2/en/docs/building-blocks/model.md index 179679088..bb629131b 100644 --- a/docs/v2/en/docs/building-blocks/model.md +++ b/docs/v2/en/docs/building-blocks/model.md @@ -5,7 +5,7 @@ description: "Configure and connect LLM model providers in AgentScope Java" ## Overview -The model layer is two-tiered: at the top sit **Credentials** (`io.agentscope.core.credential`), which carry a provider's API auth fields; below them sit **Chat Models** (`io.agentscope.core.model`), the concrete inference implementations attached to a credential. +The model layer is two-tiered: at the top sit **Credentials** (`io.agentscope.core.credential`), which carry a provider's API auth fields; below them sit **Chat Models**, the concrete inference implementations attached to a credential. ```text CredentialBase/ @@ -21,6 +21,85 @@ A **Credential** carries a provider's API auth fields (`apiKey`, `baseUrl`, …) This layering matches the natural UX in a frontend — register the credential first, then pick a model under it — so the UI authenticates once and shows everything that provider supports. +## Provider module migration + +Provider-specific model implementations have been moved out of `agentscope-core` into independent extension modules. The core module now keeps the shared model contracts and runtime utilities, while each provider module owns its chat model, credential, formatter, DTO, exception, and SDK/API client code. + +| Provider | Maven artifact | Main package | +|----------|----------------|--------------| +| OpenAI | `agentscope-extensions-model-openai` | `io.agentscope.extensions.model.openai` | +| DashScope | `agentscope-extensions-model-dashscope` | `io.agentscope.extensions.model.dashscope` | +| Gemini | `agentscope-extensions-model-gemini` | `io.agentscope.extensions.model.gemini` | +| Anthropic | `agentscope-extensions-model-anthropic` | `io.agentscope.extensions.model.anthropic` | +| Ollama | `agentscope-extensions-model-ollama` | `io.agentscope.extensions.model.ollama` | + +### Migration checklist + +1. Add the provider extension module dependency. For example, DashScope: + +```xml + + io.agentscope + agentscope-extensions-model-dashscope + +``` + +Other provider artifacts follow the same pattern: `agentscope-extensions-model-openai`, `agentscope-extensions-model-gemini`, `agentscope-extensions-model-anthropic`, and `agentscope-extensions-model-ollama`. + +2. Replace provider imports from `io.agentscope.core.model.*` with `io.agentscope.extensions.model..*`. +3. Replace provider formatter imports from `io.agentscope.core.formatter..*` with `io.agentscope.extensions.model..formatter.*`. +4. For Spring Boot applications, replace the generic model creation path with the matching provider-specific starter and its `agentscope..*` properties. + +```xml + + io.agentscope + agentscope-openai-spring-boot-starter + +``` + +### Non-Spring applications + +When using provider classes directly, add the corresponding extension dependency and update imports. For example: + +```java +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +``` + +Then create the model with the provider builder and pass the `Model` instance to the agent: + +```java +DashScopeChatModel model = + DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-plus") + .stream(true) + .formatter(new DashScopeChatFormatter()) + .build(); + +ReActAgent agent = + ReActAgent.builder() + .name("assistant") + .model(model) + .build(); +``` + +### Spring Boot applications + +For Spring Boot, prefer provider-specific starters such as `agentscope-openai-spring-boot-starter`, `agentscope-dashscope-spring-boot-starter`, `agentscope-gemini-spring-boot-starter`, and `agentscope-anthropic-spring-boot-starter`. These starters directly depend on the matching model extension, create Spring-managed `Model` beans, and leave the generic starter focused on common AgentScope infrastructure. + +OpenAI example: + +```yaml +agentscope: + model: + provider: openai + openai: + api-key: ${OPENAI_API_KEY} + model-name: gpt-4.1-mini + stream: true +``` + ## Chat model A **Chat Model** is the LLM driving conversation and tool calling, with input and output potentially spanning multiple modalities. AgentScope Java currently ships: @@ -42,8 +121,8 @@ Each chat model is built with a builder. The most common fields are `apiKey`, `m ::::{tab-set} :::{tab-item} Streaming ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; DashScopeChatModel model = DashScopeChatModel.builder() @@ -56,8 +135,8 @@ DashScopeChatModel model = ::: :::{tab-item} Tools ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; DashScopeChatModel model = @@ -75,8 +154,8 @@ DashScopeChatModel model = ::: :::{tab-item} Reasoning ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; DashScopeChatModel model = @@ -113,9 +192,9 @@ The `Model` interface exposes a unified `stream(messages, tools, options)` retur ```java import io.agentscope.core.message.UserMessage; import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import java.util.List; DashScopeChatModel model = @@ -195,8 +274,8 @@ A **Formatter** converts AgentScope `Msg` objects into the request payload each To switch to multi-agent mode, just pass the MultiAgent variant — no agent code changes: ```java -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; DashScopeChatModel model = DashScopeChatModel.builder() @@ -207,7 +286,7 @@ DashScopeChatModel model = .build(); ``` -Per-provider formatters (under `io.agentscope.core.formatter`): +Per-provider formatters now live with their provider extension modules: | Provider | Chat | MultiAgent | |----------|------|------------| diff --git a/docs/v2/zh/docs/building-blocks/agent.md b/docs/v2/zh/docs/building-blocks/agent.md index 6b95849ac..5552248ee 100644 --- a/docs/v2/zh/docs/building-blocks/agent.md +++ b/docs/v2/zh/docs/building-blocks/agent.md @@ -82,8 +82,8 @@ ReActAgent agent = :::{tab-item} 显式 Model builder ```java import io.agentscope.core.ReActAgent; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.tool.Toolkit; ReActAgent agent = diff --git a/docs/v2/zh/docs/building-blocks/model.md b/docs/v2/zh/docs/building-blocks/model.md index e2959860f..1725bbf06 100644 --- a/docs/v2/zh/docs/building-blocks/model.md +++ b/docs/v2/zh/docs/building-blocks/model.md @@ -5,7 +5,7 @@ description: "在 AgentScope Java 中配置并连接 LLM 模型提供商" ## 概述 -模型层采用两层结构:上层是 **Credential**(`io.agentscope.core.credential`),承载某个提供商的 API 鉴权字段;下层是 **Chat Model**(`io.agentscope.core.model`),即在该凭证基础上对接的具体推理模型实现。 +模型层采用两层结构:上层是 **Credential**(`io.agentscope.core.credential`),承载某个提供商的 API 鉴权字段;下层是 **Chat Model**,即在该凭证基础上对接的具体推理模型实现。 ```text CredentialBase/ @@ -21,6 +21,85 @@ CredentialBase/ 这种分层与前端的自然交互流程一致 —— 先注册凭证,再从凭证下挑选模型 —— 让界面只需鉴权一次,就能展示该提供商支持的所有模型。 +## Provider 模块迁移 + +Provider-specific 的模型实现已经从 `agentscope-core` 迁移到独立 extension module 中。core 现在只保留共享模型契约与运行时工具;每个 provider module 自己维护 chat model、credential、formatter、DTO、异常、SDK/API client 代码。 + +| Provider | Maven artifact | 主要包名 | +|----------|----------------|----------| +| OpenAI | `agentscope-extensions-model-openai` | `io.agentscope.extensions.model.openai` | +| DashScope | `agentscope-extensions-model-dashscope` | `io.agentscope.extensions.model.dashscope` | +| Gemini | `agentscope-extensions-model-gemini` | `io.agentscope.extensions.model.gemini` | +| Anthropic | `agentscope-extensions-model-anthropic` | `io.agentscope.extensions.model.anthropic` | +| Ollama | `agentscope-extensions-model-ollama` | `io.agentscope.extensions.model.ollama` | + +### 迁移步骤 + +1. 增加对应 provider extension module 依赖。以 DashScope 为例: + +```xml + + io.agentscope + agentscope-extensions-model-dashscope + +``` + +其他 provider artifact 遵循同样模式:`agentscope-extensions-model-openai`、`agentscope-extensions-model-gemini`、`agentscope-extensions-model-anthropic`、`agentscope-extensions-model-ollama`。 + +2. 将 provider 模型 import 从 `io.agentscope.core.model.*` 改为 `io.agentscope.extensions.model..*`。 +3. 将 provider formatter import 从 `io.agentscope.core.formatter..*` 改为 `io.agentscope.extensions.model..formatter.*`。 +4. Spring Boot 应用中,改用对应 provider-specific starter 和 `agentscope..*` 配置: + +```xml + + io.agentscope + agentscope-openai-spring-boot-starter + +``` + +### 非 Spring 应用 + +直接使用 provider 类时,需要引入对应 extension 依赖,并更新 import。例如: + +```java +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +``` + +然后通过 provider builder 创建模型,并把 `Model` 实例传给 agent: + +```java +DashScopeChatModel model = + DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-plus") + .stream(true) + .formatter(new DashScopeChatFormatter()) + .build(); + +ReActAgent agent = + ReActAgent.builder() + .name("assistant") + .model(model) + .build(); +``` + +### Spring Boot 应用 + +Spring Boot 场景下,优先使用 provider-specific starter,例如 `agentscope-openai-spring-boot-starter`、`agentscope-dashscope-spring-boot-starter`、`agentscope-gemini-spring-boot-starter`、`agentscope-anthropic-spring-boot-starter`。这些 starter 直接依赖对应 model extension,创建 Spring 管理的 `Model` bean,通用的 `agentscope-spring-boot-starter` 继续负责 AgentScope 的公共基础设施。 + +OpenAI 示例: + +```yaml +agentscope: + model: + provider: openai + openai: + api-key: ${OPENAI_API_KEY} + model-name: gpt-4.1-mini + stream: true +``` + ## Chat Model **Chat Model** 是驱动 agent 对话与工具调用的 LLM,输入输出可以是文本之外的多模态内容。AgentScope Java 当前提供以下 Chat Model 类: @@ -42,8 +121,8 @@ CredentialBase/ ::::{tab-set} :::{tab-item} Streaming ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; DashScopeChatModel model = DashScopeChatModel.builder() @@ -56,8 +135,8 @@ DashScopeChatModel model = ::: :::{tab-item} Tools ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; DashScopeChatModel model = @@ -75,8 +154,8 @@ DashScopeChatModel model = ::: :::{tab-item} Reasoning ```java -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; DashScopeChatModel model = @@ -113,9 +192,9 @@ DashScopeChatModel model = ```java import io.agentscope.core.message.UserMessage; import io.agentscope.core.model.ChatResponse; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; import io.agentscope.core.model.GenerateOptions; -import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeChatFormatter; import java.util.List; DashScopeChatModel model = @@ -195,8 +274,8 @@ WeatherInfo info = msg.getStructuredData(WeatherInfo.class); 切换到多 agent 模式只需传入 MultiAgent 变体,无需修改 agent 代码: ```java -import io.agentscope.core.formatter.dashscope.DashScopeMultiAgentFormatter; -import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.extensions.model.dashscope.formatter.DashScopeMultiAgentFormatter; +import io.agentscope.extensions.model.dashscope.DashScopeChatModel; DashScopeChatModel model = DashScopeChatModel.builder() @@ -207,7 +286,7 @@ DashScopeChatModel model = .build(); ``` -各 provider 的 formatter 类(位于 `io.agentscope.core.formatter`): +各 provider 的 formatter 类现在随 provider extension module 一起提供: | Provider | Chat | MultiAgent | |---|---|---| From 39085925363d5b944996b00cb135b4caac53e018 Mon Sep 17 00:00:00 2001 From: Ken Liu Date: Wed, 1 Jul 2026 20:23:50 +0800 Subject: [PATCH 3/4] fix(dashscope): enhance support for native structured output in DashScope models (#1935) Fixes #1865,#1743,#1640 --- .../java/io/agentscope/core/ReActAgent.java | 24 ++++++- .../model/dashscope/DashScopeChatModel.java | 40 ++++++++++-- .../formatter/DashScopeToolsHelper.java | 7 +++ .../formatter/OpenAIResponseParser.java | 22 +++++-- docs/v2/en/docs/building-blocks/model.md | 62 ++++++++++++++----- docs/v2/zh/docs/building-blocks/model.md | 62 ++++++++++++++----- 6 files changed, 177 insertions(+), 40 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 17199b312..251025960 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -1040,7 +1040,17 @@ private Mono doStructuredCall(List msgs, Class targetClass, JsonNod ? model.supportsNativeStructuredOutputWithTools() : model.supportsNativeStructuredOutput(); if (useNative) { - return doNativeStructuredCall(msgs, jsonSchema); + return doNativeStructuredCall(msgs, jsonSchema) + .onErrorResume( + e -> { + log.warn( + "Native structured output failed ({}) — falling back to" + + " synthetic tool path", + e.getMessage() != null + ? e.getMessage() + : e.getClass().getSimpleName()); + return doFallbackStructuredCall(msgs, jsonSchema); + }); } return doFallbackStructuredCall(msgs, jsonSchema); } @@ -1077,11 +1087,21 @@ private Mono doNativeStructuredCall(List msgs, Map jso .strict(true) .build()); + int contextSizeBefore = scope.state.contextMutable().size(); + return scope.doCallInner(msgs) .flatMap( result -> { Msg out = wrapNativeStructuredResult(result); return saveStateToSession(scope).thenReturn(out); + }) + .doOnError( + e -> { + List ctx = scope.state.contextMutable(); + while (ctx.size() > contextSizeBefore) { + ctx.remove(ctx.size() - 1); + } + scope.nativeResponseFormat = null; }); }); } @@ -1914,7 +1934,7 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { event.getEffectiveGenerateOptions() != null ? event.getEffectiveGenerateOptions() : buildGenerateOptions(); - if (nativeResponseFormat != null) { + if (nativeResponseFormat != null && soTool == null) { options = GenerateOptions.mergeOptions( GenerateOptions.builder() diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModel.java index 92a83efc0..17e8e1af4 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/DashScopeChatModel.java @@ -69,6 +69,7 @@ public class DashScopeChatModel extends ChatModelBase { private final boolean stream; private final Boolean enableThinking; // nullable private final Boolean enableSearch; // nullable + private Boolean nativeStructuredOutput; // nullable, set by Builder private final EndpointType endpointType; private final GenerateOptions defaultOptions; private final Formatter formatter; @@ -160,6 +161,7 @@ public DashScopeChatModel( this.stream = enableThinking != null && enableThinking ? true : stream; this.enableThinking = enableThinking; this.enableSearch = enableSearch; + this.nativeStructuredOutput = null; this.endpointType = endpointType != null ? endpointType : EndpointType.AUTO; this.defaultOptions = defaultOptions != null ? defaultOptions : GenerateOptions.builder().build(); @@ -392,7 +394,13 @@ public String getModelName() { @Override public boolean supportsNativeStructuredOutput() { - return true; + if (Boolean.TRUE.equals(enableThinking)) { + return false; + } + if (nativeStructuredOutput != null) { + return nativeStructuredOutput; + } + return false; } public static class Builder { @@ -409,6 +417,7 @@ public static class Builder { private boolean enableEncrypt = false; private ProxyConfig proxyConfig; private int contextWindowSize = -1; + private Boolean nativeStructuredOutput; private Boolean nativeStructuredOutputWithTools; /** @@ -639,13 +648,33 @@ public Builder contextWindowSize(int contextWindowSize) { return this; } + /** + * Sets whether this model supports native structured output via {@code response_format} + * with {@code json_schema} type. + * + *

Defaults to {@code false}. DashScope's native endpoint only supports + * {@code json_object} (free-form JSON), not {@code json_schema} (strict schema + * validation). When {@code false}, the framework uses the {@code generate_response} + * tool fallback for structured output requests. + * + *

Set to {@code true} only if your model/endpoint is confirmed to support + * {@code json_schema} in {@code response_format}. + * + * @param nativeStructuredOutput true to enable native json_schema path + * @return this builder instance + */ + public Builder nativeStructuredOutput(boolean nativeStructuredOutput) { + this.nativeStructuredOutput = nativeStructuredOutput; + return this; + } + /** * Sets whether this model correctly handles native structured output * ({@code response_format}) alongside tool calling. * - *

Defaults to {@code true}, which is correct for Qwen models on DashScope. - * Set to {@code false} for third-party models hosted on DashScope that - * prioritise {@code response_format} over tool invocations. + *

Defaults to {@code false} (inherits from + * {@link #nativeStructuredOutput(boolean)}). Set to {@code true} only for models + * that support both {@code response_format} and tool calling simultaneously. * * @param nativeStructuredOutputWithTools false to use fallback when tools are present * @return this builder instance @@ -707,6 +736,9 @@ public DashScopeChatModel build() { contextWindowSize >= 0 ? contextWindowSize : ModelContextWindows.lookup(modelName, ModelContextWindows.DASHSCOPE)); + if (nativeStructuredOutput != null) { + model.nativeStructuredOutput = nativeStructuredOutput; + } if (nativeStructuredOutputWithTools != null) { model.setNativeStructuredOutputWithTools(nativeStructuredOutputWithTools); } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/formatter/DashScopeToolsHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/formatter/DashScopeToolsHelper.java index 3e672529e..e5e3bcc40 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/formatter/DashScopeToolsHelper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-dashscope/src/main/java/io/agentscope/extensions/model/dashscope/formatter/DashScopeToolsHelper.java @@ -15,6 +15,7 @@ */ package io.agentscope.extensions.model.dashscope.formatter; +import io.agentscope.core.formatter.ResponseFormat; import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.model.ToolChoice; @@ -103,6 +104,12 @@ public void applyOptions( if (parallelToolCalls != null) { params.setParallelToolCalls(parallelToolCalls); } + + ResponseFormat responseFormat = + getOption(options, defaultOptions, GenerateOptions::getResponseFormat); + if (responseFormat != null) { + params.setResponseFormat(responseFormat); + } } /** diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIResponseParser.java index 4a41e1aa9..1e4e94023 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIResponseParser.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/formatter/OpenAIResponseParser.java @@ -250,12 +250,22 @@ protected ChatResponse parseCompletionResponse(OpenAIResponse response, Instant Map argsMap = new HashMap<>(); if (!arguments.isEmpty()) { - @SuppressWarnings("unchecked") - Map parsed = - JsonUtils.getJsonCodec() - .fromJson(arguments, Map.class); - if (parsed != null) { - argsMap.putAll(parsed); + try { + @SuppressWarnings("unchecked") + Map parsed = + JsonUtils.getJsonCodec() + .fromJson(arguments, Map.class); + if (parsed != null) { + argsMap.putAll(parsed); + } + } catch (Exception parseEx) { + log.warn( + "Failed to parse tool call arguments as JSON;" + + " preserving raw arguments: id={}," + + " name={}, error={}", + toolCallId, + name, + parseEx.getMessage()); } } diff --git a/docs/v2/en/docs/building-blocks/model.md b/docs/v2/en/docs/building-blocks/model.md index bb629131b..61408760a 100644 --- a/docs/v2/en/docs/building-blocks/model.md +++ b/docs/v2/en/docs/building-blocks/model.md @@ -247,20 +247,54 @@ WeatherInfo info = msg.getStructuredData(WeatherInfo.class); How it works: the framework synthesizes a forced structured tool call from the target class, validates and repairs the model output, and writes the result into `Msg.metadata` under the `structured_output` key, so `getStructuredData(Class)` can deserialize it directly. Complete example: `agentscope-examples/documentation/.../structuredoutput/StructuredOutputExample.java`. -> **Structured output with tool calling** -> -> When an agent has both tools and structured output, some OpenAI-compatible providers (e.g. Kimi, Deepseek) prioritise the `response_format` constraint and skip tool calling entirely. If you encounter this, set `nativeStructuredOutputWithTools(false)` when building the model — the framework will use a synthetic tool approach for structured output, fully compatible with the ReAct tool-calling loop: -> -> ```java -> OpenAIChatModel model = OpenAIChatModel.builder() -> .apiKey("...") -> .baseUrl("https://api.moonshot.cn/v1") -> .modelName("moonshot-v1-8k") -> .nativeStructuredOutputWithTools(false) -> .build(); -> ``` -> -> `DashScopeChatModel` supports this option as well. For native OpenAI models (GPT-4o, etc.) the default behavior handles both correctly — no configuration needed. +#### Structured output path selection + +The framework provides two structured output paths: + +| Path | Condition | Mechanism | +|------|-----------|-----------| +| **Native** | `supportsNativeStructuredOutput() = true` | Uses `response_format` + `json_schema` for direct JSON output | +| **Fallback** (default) | `supportsNativeStructuredOutput() = false` | Injects a `generate_response` synthetic tool; model returns structured data via tool call | + +If the native path fails (e.g. model returns HTTP 400), the framework **automatically falls back** to the synthetic tool path — no user intervention needed. + +#### Default behavior per provider + +| Provider | `supportsNativeStructuredOutput` | Notes | +|----------|----------------------------------|-------| +| OpenAI (GPT-4o, etc.) | `true` | Native `json_schema` support | +| OpenAI (DeepSeek/GLM formatter) | `false` | Not supported; auto-fallback | +| DashScope | `false` | Native endpoint only supports `json_object`, not `json_schema`; fallback by default | +| Anthropic | `false` (default) | — | + +> **DashScope users**: Thinking mode (`enableThinking(true)`) does not support structured output at all — the framework forces the fallback path. + +#### Explicit configuration + +If you confirm your model/endpoint supports `json_schema`, enable the native path via builder: + +```java +DashScopeChatModel model = DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-plus") + .nativeStructuredOutput(true) // explicitly enable native json_schema path + .build(); +``` + +#### Structured output with tool calling + +When an agent has both tools and structured output, some OpenAI-compatible providers (e.g. Kimi, Deepseek) prioritise the `response_format` constraint and skip tool calling entirely. Set `nativeStructuredOutputWithTools(false)` to resolve this: + +```java +OpenAIChatModel model = OpenAIChatModel.builder() + .apiKey("...") + .baseUrl("https://api.moonshot.cn/v1") + .modelName("moonshot-v1-8k") + .nativeStructuredOutputWithTools(false) + .build(); +``` + +`DashScopeChatModel` supports this option as well. For native OpenAI models (GPT-4o, etc.) the default behavior handles both correctly — no configuration needed. ### Formatter diff --git a/docs/v2/zh/docs/building-blocks/model.md b/docs/v2/zh/docs/building-blocks/model.md index 1725bbf06..e151411e1 100644 --- a/docs/v2/zh/docs/building-blocks/model.md +++ b/docs/v2/zh/docs/building-blocks/model.md @@ -247,20 +247,54 @@ WeatherInfo info = msg.getStructuredData(WeatherInfo.class); 实现细节:框架会基于目标 Class 合成强制结构化的工具调用,再校验并修复模型输出,最后把结果挂到 `Msg.metadata` 的 `structured_output` 字段,供 `getStructuredData(Class)` 直接反序列化。完整示例:`agentscope-examples/documentation/.../structuredoutput/StructuredOutputExample.java`。 -> **结构化输出与工具调用共存** -> -> 当 Agent 同时注册了工具并请求结构化输出时,部分 OpenAI 兼容 API(如 Kimi、Deepseek 等)会优先遵循 `response_format` 约束而跳过工具调用。如果遇到此问题,在构建 Model 时设置 `nativeStructuredOutputWithTools(false)`,框架将改用合成工具方式输出结构化结果,与工具调用完全兼容: -> -> ```java -> OpenAIChatModel model = OpenAIChatModel.builder() -> .apiKey("...") -> .baseUrl("https://api.moonshot.cn/v1") -> .modelName("moonshot-v1-8k") -> .nativeStructuredOutputWithTools(false) -> .build(); -> ``` -> -> `DashScopeChatModel` 同样支持此配置。对于 OpenAI 原生模型(GPT-4o 等)无需设置,默认行为即可正确处理。 +#### 结构化输出路径选择 + +框架提供两条结构化输出路径: + +| 路径 | 条件 | 机制 | +|------|------|------| +| **Native** | `supportsNativeStructuredOutput() = true` | 通过 `response_format` + `json_schema` 让模型直接输出合规 JSON | +| **Fallback**(默认) | `supportsNativeStructuredOutput() = false` | 注入 `generate_response` 合成工具,模型通过 tool call 返回结构化数据 | + +当 native 路径失败(如模型返回 400),框架会**自动降级**到 fallback 路径,无需用户干预。 + +#### 各 Provider 默认行为 + +| Provider | `supportsNativeStructuredOutput` | 说明 | +|----------|----------------------------------|------| +| OpenAI (GPT-4o 等) | `true` | 原生支持 `json_schema` | +| OpenAI (DeepSeek/GLM formatter) | `false` | 不支持,自动走 fallback | +| DashScope | `false` | DashScope 原生端点仅支持 `json_object`,不支持 `json_schema`;框架默认走 fallback | +| Anthropic | `false`(默认) | — | + +> **DashScope 用户注意**:DashScope 的思考模式(`enableThinking(true)`)不支持结构化输出,框架会强制走 fallback 路径。 + +#### 显式配置 + +如果确认你的模型/端点支持 `json_schema`,可以通过 builder 开启 native 路径: + +```java +DashScopeChatModel model = DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-plus") + .nativeStructuredOutput(true) // 显式开启 native json_schema 路径 + .build(); +``` + +#### 结构化输出与工具调用共存 + +当 Agent 同时注册了工具并请求结构化输出时,部分 OpenAI 兼容 API(如 Kimi、Deepseek 等)会优先遵循 `response_format` 约束而跳过工具调用。设置 `nativeStructuredOutputWithTools(false)` 可解决此问题: + +```java +OpenAIChatModel model = OpenAIChatModel.builder() + .apiKey("...") + .baseUrl("https://api.moonshot.cn/v1") + .modelName("moonshot-v1-8k") + .nativeStructuredOutputWithTools(false) + .build(); +``` + +`DashScopeChatModel` 同样支持此配置。对于 OpenAI 原生模型(GPT-4o 等)无需设置。 ### Formatter From 29a4a91c2adbaaac0c1ffe59f55ade8b5ecdd47c Mon Sep 17 00:00:00 2001 From: hansiweicn-debug Date: Wed, 1 Jul 2026 20:31:49 +0800 Subject: [PATCH 4/4] fix(harness): run message bus heartbeat on boundedElastic, not parallel (#1974) Closes #1973. --- .../agent/bus/WorkspaceMessageBus.java | 5 +- .../agent/bus/WorkspaceMessageBusTest.java | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/bus/WorkspaceMessageBus.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/bus/WorkspaceMessageBus.java index 0264ea80f..bec53890e 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/bus/WorkspaceMessageBus.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/bus/WorkspaceMessageBus.java @@ -215,9 +215,8 @@ public Mono publish(String key, Map payload) { @Override public Flux> subscribe(String key) { - return Flux.interval(POLL_INTERVAL) - .map(tick -> Map.of()) - .subscribeOn(Schedulers.boundedElastic()); + return Flux.interval(POLL_INTERVAL, Schedulers.boundedElastic()) + .map(tick -> Map.of()); } // ---- Internal helpers ---- diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/bus/WorkspaceMessageBusTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/bus/WorkspaceMessageBusTest.java index 50e10703b..a0e43a063 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/bus/WorkspaceMessageBusTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/bus/WorkspaceMessageBusTest.java @@ -18,17 +18,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.tracing.Tracer; +import io.agentscope.core.tracing.TracerRegistry; import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import reactor.core.Disposable; class WorkspaceMessageBusTest { @@ -235,4 +242,86 @@ void filesCreatedUnderBusRoot() throws IOException { .count(); assertTrue(jsonFiles >= 1, "Should have at least one .json queue entry"); } + + // ---- Scheduler affinity regression guard ---- + + /** + * Locks in that {@link WorkspaceMessageBus#subscribe(String)} emits its heartbeat ticks on + * the {@code boundedElastic} scheduler. + * + *

Why this matters: the per-tick downstream consumer ({@code WakeupDispatcher} calls + * {@code queueDrain(...).block()}) is blocking file I/O, and it runs on the same thread that + * emits the tick. It must not land on {@code parallel()} — that pool is shared, CPU-core-sized, + * and blocking it starves the global timer. Note that + * {@code Flux.interval(d).subscribeOn(boundedElastic())} does not move ticks off + * {@code parallel} ({@code subscribeOn} only relocates the subscribe call, not the timer); + * only {@code Flux.interval(d, boundedElastic())} does. This test guards against a silent + * revert to the ineffective {@code subscribeOn} form. + */ + @Test + void subscribeEmitsTicksOnBoundedElastic() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference thread = new AtomicReference<>(); + Disposable d = + bus.subscribe("regression") + .doOnNext(m -> thread.set(Thread.currentThread().getName())) + .take(1) + .subscribe(m -> latch.countDown()); + try { + // First tick arrives after POLL_INTERVAL (3s); allow headroom. + assertTrue(latch.await(5, TimeUnit.SECONDS), "no tick within 5s"); + assertNotNull(thread.get()); + assertTrue( + thread.get().startsWith("boundedElastic"), + "subscribe() tick emitted on " + thread.get() + ", expected boundedElastic"); + } finally { + d.dispose(); + } + } + + /** + * Reproduces the "open Studio → crash" scenario at the agentscope layer. + * + *

Registering a non-Noop {@link Tracer} triggers {@link TracerRegistry#enableTracingHook()}, + * which installs a global {@code Hooks.onEachOperator} lift wrapping every Reactor + * operator in the JVM (the deprecated hook smartwe's Studio integration turns on via + * {@code TelemetryTracer}). The per-tick consumer calls {@code .block()} — exactly what + * {@code WakeupDispatcher.drainAndDispatch()} does. On a {@code parallel} (NonBlocking) tick + * thread, {@code Mono.block()} throws {@code IllegalStateException}; subscribe() must therefore + * emit ticks on {@code boundedElastic} (non-NonBlocking) so blocking per-tick work is legal. + * + *

This test fails on the old {@code Flux.interval(d).subscribeOn(boundedElastic)} form and + * passes once the timer runs on {@code boundedElastic}. + */ + @Test + void subscribeTickSafeUnderGlobalTracingHook() throws InterruptedException { + // non-Noop Tracer → register() enables the global onEachOperator lift. + TracerRegistry.register(new Tracer() {}); + try { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + Disposable d = + bus.subscribe("hook-block-check") + .doOnNext( + m -> { + try { + // Mimic drainAndDispatch()'s blocking call. + bus.queuePeek("anything").block(); + } catch (Throwable t) { + error.set(t); + } + }) + .take(1) + .subscribe(m -> latch.countDown()); + try { + assertTrue(latch.await(5, TimeUnit.SECONDS), "no tick within 5s"); + assertNull(error.get(), () -> "blocking on subscribe() tick threw: " + error.get()); + } finally { + d.dispose(); + } + } finally { + // Global hook cleanup — must not leak into other tests in the JVM. + TracerRegistry.resetToNoop(); + } + } }