diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModel.java index 6f424148bd..56fce2b0ad 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/OpenAIChatModel.java @@ -62,6 +62,8 @@ * */ diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatter.java index 68d9af53f7..c8e91eedd2 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatter.java @@ -15,6 +15,275 @@ */ package io.agentscope.extensions.model.openai.compat.kimi; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.extensions.model.openai.dto.OpenAIRequest; import io.agentscope.extensions.model.openai.formatter.OpenAIChatFormatter; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -public class KimiFormatter extends OpenAIChatFormatter {} +/** + * Formatter for Kimi (Moonshot AI) models (kimi-k3, kimi-k2.7-code, kimi-k2.6, kimi-k2.5, + * moonshot-v1 series). + * + *

Adapted to the latest Kimi open platform Chat Completions API + * ({@code https://api.moonshot.cn/v1/chat/completions}): + *

+ * + *

Thinking mode: {@code kimi-k2.6} / {@code kimi-k2.5} accept a {@code thinking} body + * parameter (e.g. {@code {"type": "disabled"}}), which can be passed through + * {@code GenerateOptions.additionalBodyParam("thinking", Map.of("type", "disabled"))}. + * {@code kimi-k3} uses the top-level {@code reasoning_effort} option + * ({@code GenerateOptions.reasoningEffort}) instead. JSON mode is enabled via + * {@code response_format = {"type": "json_object"}}; {@code json_schema} is not supported. + * + *

Usage: + *

{@code
+ * OpenAIChatModel.builder()
+ *     .formatter(new KimiFormatter())
+ *     .modelName("kimi-k3")
+ *     .baseUrl("https://api.moonshot.cn/v1")
+ *     .apiKey(apiKey)
+ *     .build();
+ * }
+ * + * @see Kimi API overview + * @see Kimi model parameter + * reference + * @see Kimi thinking + * mode guide + * @see Kimi tool choice guide + */ +public class KimiFormatter extends OpenAIChatFormatter { + + private static final Logger log = LoggerFactory.getLogger(KimiFormatter.class); + + public KimiFormatter() { + super(); + } + + @Override + protected boolean supportsStrict() { + return false; + } + + @Override + public void applyOptions( + OpenAIRequest request, GenerateOptions options, GenerateOptions defaultOptions) { + super.applyOptions(request, options, defaultOptions); + sanitizeKimiRequest(request); + } + + @Override + public void applyToolChoice(OpenAIRequest request, ToolChoice toolChoice) { + applyKimiToolChoice(request, toolChoice); + } + + /** + * Whether the model belongs to the Kimi K series ({@code kimi-k3}, {@code kimi-k2.7-code}, + * {@code kimi-k2.6}, ...) whose sampling parameters are fixed by the platform. + * + * @param model the model name from the request (may be null) + * @return true for {@code kimi-*} models + */ + static boolean hasFixedSamplingParams(String model) { + return model != null && model.startsWith("kimi-"); + } + + /** + * Whether the model always runs with thinking enabled and cannot disable it + * ({@code kimi-k3} and the {@code kimi-k2.7-code} series). + * + * @param model the model name from the request (may be null) + * @return true for always-thinking Kimi models + */ + static boolean isAlwaysThinkingModel(String model) { + return model != null && (model.startsWith("kimi-k3") || model.startsWith("kimi-k2.7-code")); + } + + /** + * Whether the model supports the top-level {@code reasoning_effort} parameter. + * Per the official parameter reference, only {@code kimi-k3} supports it. + * + * @param model the model name from the request (may be null) + * @return true if {@code reasoning_effort} is supported + */ + static boolean supportsReasoningEffort(String model) { + return model != null && model.startsWith("kimi-k3"); + } + + /** + * Whether the model supports {@code tool_choice = "required"}. + * Per the official parameter reference, {@code kimi-k3} supports it while the K2.x series + * ({@code kimi-k2.6}, {@code kimi-k2.7-code}, ...) rejects it. + * + * @param model the model name from the request (may be null) + * @return true if {@code required} is supported + */ + static boolean supportsRequiredToolChoice(String model) { + return model == null || !model.startsWith("kimi-k2"); + } + + /** + * Sanitize the request according to the Kimi Chat Completions API reference. + * + *

Applied adaptations: + *

+ * + *

This method is static to allow sharing with {@link KimiMultiAgentFormatter}. + * + * @param request the request to sanitize + */ + protected static void sanitizeKimiRequest(OpenAIRequest request) { + String model = request.getModel(); + + if (hasFixedSamplingParams(model)) { + if (request.getTemperature() != null) { + log.debug( + "Kimi model {} does not allow overriding temperature, removing it", model); + request.setTemperature(null); + } + if (request.getTopP() != null) { + log.debug("Kimi model {} does not allow overriding top_p, removing it", model); + request.setTopP(null); + } + if (request.getFrequencyPenalty() != null) { + log.debug( + "Kimi model {} does not allow overriding frequency_penalty, removing it", + model); + request.setFrequencyPenalty(null); + } + if (request.getPresencePenalty() != null) { + log.debug( + "Kimi model {} does not allow overriding presence_penalty, removing it", + model); + request.setPresencePenalty(null); + } + } + + if (request.getReasoningEffort() != null && !supportsReasoningEffort(model)) { + log.debug( + "Kimi model {} does not support reasoning_effort (kimi-k3 only), removing it;" + + " use the 'thinking' body param on K2.x models instead", + model); + request.setReasoningEffort(null); + } + + if (request.getThinkingBudget() != null) { + log.debug( + "Kimi does not support thinking_budget, removing it from the request; use the" + + " 'thinking' body param (K2.x) or reasoning_effort (kimi-k3)" + + " instead"); + request.setThinkingBudget(null); + } + + // Kimi only documents max_tokens; map OpenAI-style max_completion_tokens onto it + if (request.getMaxCompletionTokens() != null) { + if (request.getMaxTokens() == null) { + log.debug("Kimi only supports max_tokens, mapping max_completion_tokens to it"); + request.setMaxTokens(request.getMaxCompletionTokens()); + } + request.setMaxCompletionTokens(null); + } + } + + /** + * Apply Kimi-specific tool choice handling. + * + *

Per the official tool choice guide: + *

+ * + *

This method is static to allow sharing with {@link KimiMultiAgentFormatter}. + * + * @param request the request to apply tool choice to + * @param toolChoice the requested tool choice + */ + protected static void applyKimiToolChoice(OpenAIRequest request, ToolChoice toolChoice) { + if (request.getTools() == null || request.getTools().isEmpty()) { + return; + } + + String model = request.getModel(); + + if (toolChoice == null || toolChoice instanceof ToolChoice.Auto) { + request.setToolChoice("auto"); + } else if (toolChoice instanceof ToolChoice.None) { + request.setToolChoice("none"); + } else if (toolChoice instanceof ToolChoice.Required) { + if (supportsRequiredToolChoice(model)) { + request.setToolChoice("required"); + } else { + log.info( + "Kimi model {} does not support tool_choice='required' (kimi-k3 only)," + + " degrading to 'auto'", + model); + request.setToolChoice("auto"); + } + } else if (toolChoice instanceof ToolChoice.Specific specific) { + if (isAlwaysThinkingModel(model)) { + // Specified tool_choice is incompatible with thinking enabled (HTTP 400), and + // these models cannot disable thinking + log.warn( + "Kimi model {} always runs with thinking enabled, which is incompatible" + + " with a specific tool_choice; degrading to 'auto'", + model); + request.setToolChoice("auto"); + } else { + Map namedToolChoice = new HashMap<>(); + namedToolChoice.put("type", "function"); + Map function = new HashMap<>(); + function.put("name", specific.toolName()); + namedToolChoice.put("function", function); + request.setToolChoice(namedToolChoice); + log.debug( + "Applied specific tool_choice '{}' for Kimi model {}; note it requires" + + " thinking to be disabled on thinking-capable models", + specific.toolName(), + model); + } + } else { + request.setToolChoice("auto"); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProvider.java new file mode 100644 index 0000000000..9b01e49ca6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProvider.java @@ -0,0 +1,206 @@ +/* + * 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.compat.kimi; + +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.OpenAIChatModel; +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; + +/** + * Kimi (Moonshot AI) provider registered through {@link java.util.ServiceLoader}. + * + *

Kimi exposes an OpenAI-compatible Chat Completions endpoint, so this provider creates + * {@link OpenAIChatModel} instances preconfigured for Kimi: + *

+ * + *

The API key is taken from {@link ModelCreationContext#getApiKey()}, then from the + * {@code MOONSHOT_API_KEY} environment variable, then from {@code KIMI_API_KEY}. + * + *

Usage: + *

{@code
+ * ReActAgent agent = ReActAgent.builder()
+ *     .name("assistant")
+ *     .model("kimi:kimi-k3") // resolved by ModelRegistry through this provider
+ *     .build();
+ * }
+ */ +public final class KimiModelProvider implements ModelProvider { + + private static final String PREFIX = "kimi:"; + private static final Pattern MODEL_ID = Pattern.compile("kimi:.+"); + private static final String DEFAULT_BASE_URL = "https://api.moonshot.cn/v1"; + private static final String OPTION_CONTEXT_WINDOW_SIZE = "contextWindowSize"; + private static final String OPTION_NATIVE_STRUCTURED_OUTPUT = "nativeStructuredOutput"; + private static final String OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS = + "nativeStructuredOutputWithTools"; + + @Override + public String providerId() { + return "kimi"; + } + + @Override + public boolean supports(String modelId) { + return modelId != null + && MODEL_ID.matcher(modelId).matches() + && trimToNull(modelId.substring(PREFIX.length())) != null; + } + + @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 Kimi model id: " + modelId); + } + // supports() guarantees the suffix is non-blank + String modelName = trimToNull(modelId.substring(PREFIX.length())); + String apiKey = + firstNonBlank( + context.getApiKey(), + System.getenv("MOONSHOT_API_KEY"), + System.getenv("KIMI_API_KEY")); + if (apiKey == null) { + throw new IllegalStateException( + "An API key is required to auto-create model " + + modelId + + ": provide it via ModelCreationContext#apiKey or the" + + " MOONSHOT_API_KEY / KIMI_API_KEY environment variable"); + } + String baseUrl = trimToNull(context.getBaseUrl()); + OpenAIChatModel.Builder builder = + OpenAIChatModel.builder() + .apiKey(apiKey) + .modelName(modelName) + .baseUrl(baseUrl != null ? baseUrl : DEFAULT_BASE_URL) + .stream(context.getStream() != null ? context.getStream() : true); + 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); + builder.formatter(formatter != null ? formatter : new KimiFormatter()); + Integer contextWindowSize = intOption(context, OPTION_CONTEXT_WINDOW_SIZE); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + // Kimi response_format only supports json_object, so native structured output is + // disabled by default; the option can explicitly re-enable it. + Boolean nativeStructuredOutput = booleanOption(context, OPTION_NATIVE_STRUCTURED_OUTPUT); + builder.nativeStructuredOutput( + nativeStructuredOutput != null ? nativeStructuredOutput : false); + // Kimi prioritises response_format over tool invocations when both are present, so + // structured output alongside tools is disabled by default as well. + Boolean nativeStructuredOutputWithTools = + booleanOption(context, OPTION_NATIVE_STRUCTURED_OUTPUT_WITH_TOOLS); + builder.nativeStructuredOutputWithTools( + nativeStructuredOutputWithTools != null ? nativeStructuredOutputWithTools : false); + } + + private static String firstNonBlank(String... candidates) { + for (String candidate : candidates) { + String normalized = trimToNull(candidate); + if (normalized != null) { + return normalized; + } + } + return null; + } + + 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/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatter.java new file mode 100644 index 0000000000..1ad50c3b05 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatter.java @@ -0,0 +1,77 @@ +/* + * 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.compat.kimi; + +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.extensions.model.openai.dto.OpenAIRequest; +import io.agentscope.extensions.model.openai.formatter.OpenAIMultiAgentFormatter; + +/** + * Multi-agent formatter for Kimi (Moonshot AI) models. + * + *

This formatter extends {@link OpenAIMultiAgentFormatter} with the same Kimi-specific + * handling as {@link KimiFormatter}: + *

+ * + *

Usage: + *

{@code
+ * OpenAIChatModel.builder()
+ *     .formatter(new KimiMultiAgentFormatter())
+ *     .modelName("kimi-k3")
+ *     .baseUrl("https://api.moonshot.cn/v1")
+ *     .apiKey(apiKey)
+ *     .build();
+ * }
+ * + * @see KimiFormatter + */ +public class KimiMultiAgentFormatter extends OpenAIMultiAgentFormatter { + + public KimiMultiAgentFormatter() { + super(); + } + + public KimiMultiAgentFormatter(String conversationHistoryPrompt) { + super(conversationHistoryPrompt); + } + + @Override + protected boolean supportsStrict() { + return false; + } + + @Override + public void applyOptions( + OpenAIRequest request, GenerateOptions options, GenerateOptions defaultOptions) { + super.applyOptions(request, options, defaultOptions); + KimiFormatter.sanitizeKimiRequest(request); + } + + @Override + public void applyToolChoice(OpenAIRequest request, ToolChoice toolChoice) { + KimiFormatter.applyKimiToolChoice(request, toolChoice); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider index ab4ba676a5..ee4642883c 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider @@ -1,3 +1,4 @@ io.agentscope.extensions.model.openai.OpenAIModelProvider +io.agentscope.extensions.model.openai.compat.kimi.KimiModelProvider io.agentscope.extensions.model.openai.compat.glm.GLMModelProvider io.agentscope.extensions.model.openai.compat.deepseek.DeepSeekModelProvider diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatterTest.java new file mode 100644 index 0000000000..91364bed66 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiFormatterTest.java @@ -0,0 +1,485 @@ +/* + * 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.compat.kimi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.openai.dto.OpenAIMessage; +import io.agentscope.extensions.model.openai.dto.OpenAIRequest; +import io.agentscope.extensions.model.openai.dto.OpenAITool; +import io.agentscope.extensions.model.openai.dto.OpenAIToolFunction; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link KimiFormatter}. + * + *

Tests verify Kimi (Moonshot AI)-specific requirements per the latest official API + * reference: + *

+ */ +@Tag("unit") +@DisplayName("KimiFormatter (compat.kimi) Unit Tests") +class KimiFormatterTest { + + private KimiFormatter formatter; + + @BeforeEach + void setUp() { + formatter = new KimiFormatter(); + } + + private static OpenAIRequest requestFor(String model) { + return OpenAIRequest.builder().model(model).messages(List.of()).build(); + } + + private static OpenAIRequest requestWithTools(String model) { + OpenAIToolFunction function = new OpenAIToolFunction(); + function.setName("get_weather"); + OpenAITool tool = new OpenAITool(); + tool.setFunction(function); + tool.setType("function"); + + return OpenAIRequest.builder() + .model(model) + .messages(List.of()) + .tools(List.of(tool)) + .build(); + } + + @Nested + @DisplayName("supportsStrict Tests") + class SupportsStrictTests { + + @Test + @DisplayName("supportsStrict should return false") + void testSupportsStrictReturnsFalse() { + assertFalse(formatter.supportsStrict()); + } + + @Test + @DisplayName("applyTools should not include strict parameter") + void testApplyToolsWithoutStrict() { + OpenAIRequest request = requestFor("kimi-k3"); + + ToolSchema tool = + ToolSchema.builder() + .name("test_tool") + .description("Test tool") + .strict(true) + .build(); + + formatter.applyTools(request, List.of(tool)); + + assertNotNull(request.getTools()); + assertEquals(1, request.getTools().size()); + // Strict should not be set because Kimi doesn't document it + assertNull(request.getTools().get(0).getFunction().getStrict()); + } + } + + @Nested + @DisplayName("Model Classification Tests") + class ModelClassificationTests { + + @Test + @DisplayName("kimi-* models have fixed sampling params") + void testFixedSamplingParams() { + assertTrue(KimiFormatter.hasFixedSamplingParams("kimi-k3")); + assertTrue(KimiFormatter.hasFixedSamplingParams("kimi-k2.7-code")); + assertTrue(KimiFormatter.hasFixedSamplingParams("kimi-k2.6")); + assertFalse(KimiFormatter.hasFixedSamplingParams("moonshot-v1-8k")); + assertFalse(KimiFormatter.hasFixedSamplingParams(null)); + } + + @Test + @DisplayName("kimi-k3 and kimi-k2.7-code are always-thinking models") + void testAlwaysThinkingModels() { + assertTrue(KimiFormatter.isAlwaysThinkingModel("kimi-k3")); + assertTrue(KimiFormatter.isAlwaysThinkingModel("kimi-k2.7-code")); + assertTrue(KimiFormatter.isAlwaysThinkingModel("kimi-k2.7-code-highspeed")); + // Only the documented kimi-k2.7-code series is always-thinking; a plain + // kimi-k2.7* model must not have its tool_choice degraded + assertFalse(KimiFormatter.isAlwaysThinkingModel("kimi-k2.7")); + assertFalse(KimiFormatter.isAlwaysThinkingModel("kimi-k2.6")); + assertFalse(KimiFormatter.isAlwaysThinkingModel("moonshot-v1-8k")); + assertFalse(KimiFormatter.isAlwaysThinkingModel(null)); + } + + @Test + @DisplayName("Only kimi-k3 supports reasoning_effort") + void testSupportsReasoningEffort() { + assertTrue(KimiFormatter.supportsReasoningEffort("kimi-k3")); + assertFalse(KimiFormatter.supportsReasoningEffort("kimi-k2.6")); + assertFalse(KimiFormatter.supportsReasoningEffort("kimi-k2.7-code")); + assertFalse(KimiFormatter.supportsReasoningEffort("moonshot-v1-8k")); + assertFalse(KimiFormatter.supportsReasoningEffort(null)); + } + + @Test + @DisplayName("K2.x series does not support tool_choice=required") + void testSupportsRequiredToolChoice() { + assertTrue(KimiFormatter.supportsRequiredToolChoice("kimi-k3")); + assertFalse(KimiFormatter.supportsRequiredToolChoice("kimi-k2.6")); + assertFalse(KimiFormatter.supportsRequiredToolChoice("kimi-k2.7-code")); + assertTrue(KimiFormatter.supportsRequiredToolChoice("moonshot-v1-8k")); + // Unknown model: keep the caller-requested value rather than degrade it + assertTrue(KimiFormatter.supportsRequiredToolChoice(null)); + } + } + + @Nested + @DisplayName("applyOptions Tests") + class ApplyOptionsTests { + + @Test + @DisplayName("Should strip fixed sampling params on kimi-* models") + void testStripFixedSamplingParamsOnKimiModels() { + OpenAIRequest request = requestFor("kimi-k3"); + + GenerateOptions options = + GenerateOptions.builder() + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.5) + .build(); + + formatter.applyOptions(request, options, null); + + assertNull(request.getTemperature()); + assertNull(request.getTopP()); + assertNull(request.getFrequencyPenalty()); + assertNull(request.getPresencePenalty()); + } + + @Test + @DisplayName("Should keep sampling params on moonshot-v1 models") + void testKeepSamplingParamsOnMoonshotModels() { + OpenAIRequest request = requestFor("moonshot-v1-8k"); + + GenerateOptions options = + GenerateOptions.builder() + .temperature(0.3) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.5) + .build(); + + formatter.applyOptions(request, options, null); + + assertEquals(0.3, request.getTemperature()); + assertEquals(0.9, request.getTopP()); + assertEquals(0.5, request.getFrequencyPenalty()); + assertEquals(0.5, request.getPresencePenalty()); + } + + @Test + @DisplayName("Should keep reasoning_effort on kimi-k3") + void testKeepReasoningEffortOnK3() { + OpenAIRequest request = requestFor("kimi-k3"); + + GenerateOptions options = GenerateOptions.builder().reasoningEffort("high").build(); + + formatter.applyOptions(request, options, null); + + assertEquals("high", request.getReasoningEffort()); + } + + @Test + @DisplayName("Should strip reasoning_effort on K2.x models") + void testStripReasoningEffortOnK2() { + OpenAIRequest request = requestFor("kimi-k2.6"); + + GenerateOptions options = GenerateOptions.builder().reasoningEffort("high").build(); + + formatter.applyOptions(request, options, null); + + assertNull(request.getReasoningEffort()); + } + + @Test + @DisplayName("Should strip thinking_budget") + void testStripThinkingBudget() { + OpenAIRequest request = requestFor("kimi-k2.6"); + + GenerateOptions options = GenerateOptions.builder().thinkingBudget(2048).build(); + + formatter.applyOptions(request, options, null); + + assertNull(request.getThinkingBudget()); + } + + @Test + @DisplayName("Should map max_completion_tokens to max_tokens") + void testMapMaxCompletionTokensToMaxTokens() { + OpenAIRequest request = requestFor("kimi-k3"); + + GenerateOptions options = GenerateOptions.builder().maxCompletionTokens(32768).build(); + + formatter.applyOptions(request, options, null); + + assertEquals(32768, request.getMaxTokens()); + assertNull(request.getMaxCompletionTokens()); + } + + @Test + @DisplayName("max_tokens should take precedence over max_completion_tokens") + void testMaxTokensTakesPrecedence() { + OpenAIRequest request = requestFor("kimi-k3"); + + GenerateOptions options = + GenerateOptions.builder().maxTokens(16000).maxCompletionTokens(32768).build(); + + formatter.applyOptions(request, options, null); + + assertEquals(16000, request.getMaxTokens()); + assertNull(request.getMaxCompletionTokens()); + } + + @Test + @DisplayName("Should pass through the thinking body param for K2.x models") + void testThinkingBodyParamPassThrough() { + OpenAIRequest request = requestFor("kimi-k2.6"); + + GenerateOptions options = + GenerateOptions.builder() + .additionalBodyParam("thinking", Map.of("type", "disabled")) + .build(); + + formatter.applyOptions(request, options, null); + + assertNotNull(request.getExtraParams()); + assertEquals(Map.of("type", "disabled"), request.getExtraParams().get("thinking")); + } + } + + @Nested + @DisplayName("applyKimiToolChoice Tests") + class ApplyKimiToolChoiceTests { + + @Test + @DisplayName("Should set tool_choice to auto for Auto") + void testToolChoiceAuto() { + OpenAIRequest request = requestWithTools("kimi-k3"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Auto()); + + assertEquals("auto", request.getToolChoice()); + } + + @Test + @DisplayName("Should set tool_choice to none for None") + void testToolChoiceNone() { + OpenAIRequest request = requestWithTools("kimi-k2.6"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.None()); + + assertEquals("none", request.getToolChoice()); + } + + @Test + @DisplayName("Should keep required on kimi-k3") + void testToolChoiceRequiredOnK3() { + OpenAIRequest request = requestWithTools("kimi-k3"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Required()); + + assertEquals("required", request.getToolChoice()); + } + + @Test + @DisplayName("Should degrade required to auto on kimi-k2.6") + void testToolChoiceRequiredDegradesOnK26() { + OpenAIRequest request = requestWithTools("kimi-k2.6"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Required()); + + assertEquals("auto", request.getToolChoice()); + } + + @Test + @DisplayName("Should degrade required to auto on kimi-k2.7-code") + void testToolChoiceRequiredDegradesOnK27Code() { + OpenAIRequest request = requestWithTools("kimi-k2.7-code"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Required()); + + assertEquals("auto", request.getToolChoice()); + } + + @Test + @DisplayName("Should degrade specific to auto on always-thinking models") + void testToolChoiceSpecificDegradesOnAlwaysThinkingModels() { + OpenAIRequest request = requestWithTools("kimi-k3"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Specific("get_weather")); + + assertEquals("auto", request.getToolChoice()); + } + + @Test + @DisplayName("Should keep specific tool_choice on kimi-k2.6") + @SuppressWarnings("unchecked") + void testToolChoiceSpecificKeptOnK26() { + OpenAIRequest request = requestWithTools("kimi-k2.6"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Specific("get_weather")); + + assertTrue(request.getToolChoice() instanceof Map); + Map choice = (Map) request.getToolChoice(); + assertEquals("function", choice.get("type")); + assertEquals("get_weather", ((Map) choice.get("function")).get("name")); + } + + @Test + @DisplayName("Should not set tool_choice if no tools") + void testNoToolChoiceWithoutTools() { + OpenAIRequest request = requestFor("kimi-k3"); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Auto()); + + assertNull(request.getToolChoice()); + } + + @Test + @DisplayName("Should not set tool_choice if tools list is empty") + void testNoToolChoiceWithEmptyTools() { + OpenAIRequest request = + OpenAIRequest.builder() + .model("kimi-k3") + .messages(List.of()) + .tools(List.of()) + .build(); + + KimiFormatter.applyKimiToolChoice(request, new ToolChoice.Auto()); + + assertNull(request.getToolChoice()); + } + + @Test + @DisplayName("Should handle null toolChoice") + void testNullToolChoice() { + OpenAIRequest request = requestWithTools("kimi-k3"); + + KimiFormatter.applyKimiToolChoice(request, null); + + assertEquals("auto", request.getToolChoice()); + } + } + + @Nested + @DisplayName("doFormat / Preserved Thinking Tests") + class DoFormatTests { + + @Test + @DisplayName("Should format basic conversation") + void testFormatBasicConversation() { + List messages = + List.of( + Msg.builder() + .role(MsgRole.SYSTEM) + .content( + List.of( + TextBlock.builder() + .text("You are Kimi") + .build())) + .build(), + Msg.builder() + .role(MsgRole.USER) + .content(List.of(TextBlock.builder().text("Hello").build())) + .build()); + + List result = formatter.format(messages); + + assertEquals(2, result.size()); + assertEquals("system", result.get(0).getRole()); + assertEquals("user", result.get(1).getRole()); + } + + @Test + @DisplayName("Should preserve reasoning_content in assistant history") + void testPreservesReasoningContent() { + List messages = + List.of( + Msg.builder() + .role(MsgRole.USER) + .content( + List.of( + TextBlock.builder() + .text("First question") + .build())) + .build(), + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + List.of( + ThinkingBlock.builder() + .thinking("Let me think...") + .build(), + TextBlock.builder().text("The answer").build())) + .build()); + + List result = formatter.format(messages); + + assertEquals(2, result.size()); + OpenAIMessage assistantMsg = result.get(1); + assertEquals("assistant", assistantMsg.getRole()); + // Preserved Thinking: kimi-k3 / kimi-k2.7-code require reasoning_content to be + // passed back in the message history + assertEquals("Let me think...", assistantMsg.getReasoningContent()); + } + } + + @Nested + @DisplayName("applyToolChoice Integration Tests") + class ApplyToolChoiceIntegrationTests { + + @Test + @DisplayName("Should apply tool choice through formatter") + void testApplyToolChoiceThroughFormatter() { + OpenAIRequest request = requestWithTools("kimi-k2.6"); + + formatter.applyToolChoice(request, new ToolChoice.Required()); + + // kimi-k2.6 does not support required, degraded to auto + assertEquals("auto", request.getToolChoice()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProviderTest.java new file mode 100644 index 0000000000..716b2dcb6e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiModelProviderTest.java @@ -0,0 +1,289 @@ +/* + * 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.compat.kimi; + +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 static org.junit.jupiter.api.Assumptions.assumeTrue; + +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.HttpRequest; +import io.agentscope.core.model.transport.HttpResponse; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.model.transport.ProxyConfig; +import io.agentscope.extensions.model.openai.OpenAIChatModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +class KimiModelProviderTest { + + @AfterEach + void tearDown() { + ModelRegistry.reset(); + } + + @Test + void providerIdIsKimi() { + assertEquals("kimi", new KimiModelProvider().providerId()); + } + + @Test + void supportsKimiModelIds() { + KimiModelProvider provider = new KimiModelProvider(); + assertTrue(provider.supports("kimi:kimi-k3")); + assertTrue(provider.supports("kimi:kimi-k2.6")); + assertTrue(provider.supports("kimi:moonshot-v1-8k")); + assertFalse(provider.supports("kimi:")); + // Whitespace-only model names must not be treated as supported + assertFalse(provider.supports("kimi: ")); + assertFalse(provider.supports("kimi: ")); + assertFalse(provider.supports("openai:gpt-4o-mini")); + assertFalse(provider.supports(null)); + } + + @Test + void createTrimsModelName() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder().apiKey("test-kimi-key").build(); + + Model model = provider.create("kimi: kimi-k3", context); + + assertEquals("kimi-k3", model.getModelName()); + } + + @Test + void createRejectsUnsupportedModelIdsBeforeReadingEnvironment() { + KimiModelProvider provider = new KimiModelProvider(); + + assertThrows(IllegalArgumentException.class, () -> provider.create("kimi:")); + assertThrows(IllegalArgumentException.class, () -> provider.create("kimi: ")); + assertThrows(IllegalArgumentException.class, () -> provider.create("kimi-k3")); + assertThrows(IllegalArgumentException.class, () -> provider.create(null)); + } + + @Test + void createUsesModelCreationContext() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder().apiKey("test-kimi-key").stream(false) + .component(GenerateOptions.class, GenerateOptions.builder().build()) + .component(ProxyConfig.class, ProxyConfig.http("localhost", 8080)) + .option("contextWindowSize", 262144) + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model instanceof OpenAIChatModel); + assertEquals("kimi-k3", model.getModelName()); + assertEquals(262144, model.getContextWindowSize()); + } + + @Test + void nativeStructuredOutputDisabledByDefault() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder().apiKey("test-kimi-key").build(); + + Model model = provider.create("kimi:kimi-k3", context); + + // Kimi response_format only supports json_object, so native structured + // output falls back to the generate_response tool by default + assertFalse(model.supportsNativeStructuredOutput()); + } + + @Test + void nativeStructuredOutputCanBeEnabledByOption() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .option("nativeStructuredOutput", true) + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model.supportsNativeStructuredOutput()); + } + + @Test + void nativeStructuredOutputWithToolsDisabledByDefault() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder().apiKey("test-kimi-key").build(); + + Model model = provider.create("kimi:kimi-k3", context); + + // Kimi prioritises response_format over tool invocations when both are present + assertFalse(model.supportsNativeStructuredOutputWithTools()); + } + + @Test + void nativeStructuredOutputWithToolsCanBeEnabledByOption() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .option("nativeStructuredOutput", true) + .option("nativeStructuredOutputWithTools", true) + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model.supportsNativeStructuredOutputWithTools()); + } + + @Test + void contextWindowSizeOptionMustBeANumber() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .option("contextWindowSize", "not-a-number") + .build(); + + assertThrows( + IllegalArgumentException.class, () -> provider.create("kimi:kimi-k3", context)); + } + + @Test + void nativeStructuredOutputOptionsMustBeBooleans() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext structuredOutputContext = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .option("nativeStructuredOutput", "yes") + .build(); + ModelCreationContext structuredOutputWithToolsContext = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .option("nativeStructuredOutputWithTools", "yes") + .build(); + + assertThrows( + IllegalArgumentException.class, + () -> provider.create("kimi:kimi-k3", structuredOutputContext)); + assertThrows( + IllegalArgumentException.class, + () -> provider.create("kimi:kimi-k3", structuredOutputWithToolsContext)); + } + + @Test + void createWithoutApiKeyThrowsIllegalStateException() { + // Only meaningful when the environment provides no Kimi API key either + assumeTrue(isBlank(System.getenv("MOONSHOT_API_KEY"))); + assumeTrue(isBlank(System.getenv("KIMI_API_KEY"))); + + KimiModelProvider provider = new KimiModelProvider(); + + IllegalStateException fromEmptyContext = + assertThrows(IllegalStateException.class, () -> provider.create("kimi:kimi-k3")); + assertTrue(fromEmptyContext.getMessage().contains("MOONSHOT_API_KEY")); + assertTrue(fromEmptyContext.getMessage().contains("KIMI_API_KEY")); + + // A blank context API key must fall through to the environment lookup + ModelCreationContext blankKeyContext = ModelCreationContext.builder().apiKey(" ").build(); + assertThrows( + IllegalStateException.class, + () -> provider.create("kimi:kimi-k3", blankKeyContext)); + } + + @Test + void endpointPathIsApplied() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .endpointPath("/custom/chat/completions") + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model instanceof OpenAIChatModel); + } + + @Test + void httpTransportComponentIsApplied() { + KimiModelProvider provider = new KimiModelProvider(); + HttpTransport transport = + new HttpTransport() { + @Override + public HttpResponse execute(HttpRequest request) { + throw new UnsupportedOperationException("not used in this test"); + } + + @Override + public Flux stream(HttpRequest request) { + return Flux.empty(); + } + + @Override + public void close() {} + }; + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .component(HttpTransport.class, transport) + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model instanceof OpenAIChatModel); + } + + @Test + void customFormatterComponentTakesPrecedence() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .component(KimiMultiAgentFormatter.class, new KimiMultiAgentFormatter()) + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model instanceof OpenAIChatModel); + } + + @Test + void customBaseUrlOverridesDefault() { + KimiModelProvider provider = new KimiModelProvider(); + ModelCreationContext context = + ModelCreationContext.builder() + .apiKey("test-kimi-key") + .baseUrl("https://kimi.example.com/v1") + .build(); + + Model model = provider.create("kimi:kimi-k3", context); + + assertTrue(model instanceof OpenAIChatModel); + } + + @Test + void modelRegistryFindsKimiProviderFromServiceLoader() { + assertTrue(ModelRegistry.canResolve("kimi:kimi-k3")); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatterTest.java new file mode 100644 index 0000000000..0073f10b46 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai/src/test/java/io/agentscope/extensions/model/openai/compat/kimi/KimiMultiAgentFormatterTest.java @@ -0,0 +1,217 @@ +/* + * 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.compat.kimi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.extensions.model.openai.dto.OpenAIMessage; +import io.agentscope.extensions.model.openai.dto.OpenAIRequest; +import io.agentscope.extensions.model.openai.dto.OpenAITool; +import io.agentscope.extensions.model.openai.dto.OpenAIToolFunction; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link KimiMultiAgentFormatter}. + * + *

Tests verify Kimi multi-agent specific requirements: + *

    + *
  • Inherits multi-agent conversation merging from OpenAIMultiAgentFormatter
  • + *
  • Fixed sampling params are stripped on kimi-* models
  • + *
  • tool_choice is degraded when unsupported by the target model
  • + *
  • Does NOT send the strict parameter in tool definitions
  • + *
+ */ +@Tag("unit") +@DisplayName("KimiMultiAgentFormatter (compat.kimi) Unit Tests") +class KimiMultiAgentFormatterTest { + + private KimiMultiAgentFormatter formatter; + + @BeforeEach + void setUp() { + formatter = new KimiMultiAgentFormatter(); + } + + private static Msg textMsg(MsgRole role, String name, String text) { + return Msg.builder() + .role(role) + .name(name) + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + } + + private static OpenAIRequest requestWithTools(String model) { + OpenAIToolFunction function = new OpenAIToolFunction(); + function.setName("test_tool"); + OpenAITool tool = new OpenAITool(); + tool.setFunction(function); + tool.setType("function"); + + return OpenAIRequest.builder() + .model(model) + .messages(List.of()) + .tools(List.of(tool)) + .build(); + } + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + @DisplayName("Custom conversation history prompt should be honored") + void testCustomPromptConstructor() { + KimiMultiAgentFormatter customFormatter = + new KimiMultiAgentFormatter("# Custom History\n"); + + List messages = + List.of( + textMsg(MsgRole.USER, "Alice", "Hello"), + textMsg(MsgRole.ASSISTANT, "Bob", "Hi Alice")); + + List result = customFormatter.format(messages); + + assertFalse(result.isEmpty()); + String content = result.get(0).getContentAsString(); + assertTrue(content.contains("# Custom History")); + } + } + + @Nested + @DisplayName("Multi-agent Formatting Tests") + class MultiAgentFormattingTests { + + @Test + @DisplayName("Should merge agent conversation into a user message") + void testMergesConversationIntoUserMessage() { + List messages = + List.of( + textMsg(MsgRole.USER, "Alice", "Hello"), + textMsg(MsgRole.ASSISTANT, "Bob", "Hi Alice")); + + List result = formatter.format(messages); + + assertFalse(result.isEmpty()); + assertTrue(result.stream().anyMatch(m -> "user".equals(m.getRole()))); + } + } + + @Nested + @DisplayName("Kimi-specific Behavior Tests") + class KimiSpecificBehaviorTests { + + @Test + @DisplayName("supportsStrict should return false") + void testSupportsStrictReturnsFalse() { + assertFalse(formatter.supportsStrict()); + } + + @Test + @DisplayName("applyTools should not include strict parameter") + void testApplyToolsWithoutStrict() { + OpenAIRequest request = + OpenAIRequest.builder().model("kimi-k3").messages(List.of()).build(); + + ToolSchema tool = + ToolSchema.builder() + .name("test_tool") + .description("Test tool") + .strict(true) + .build(); + + formatter.applyTools(request, List.of(tool)); + + assertNotNull(request.getTools()); + assertNull(request.getTools().get(0).getFunction().getStrict()); + } + + @Test + @DisplayName("applyToolChoice should degrade required to auto on K2.x") + void testToolChoiceRequiredDegradesOnK2() { + OpenAIRequest request = requestWithTools("kimi-k2.6"); + + formatter.applyToolChoice(request, new ToolChoice.Required()); + + assertEquals("auto", request.getToolChoice()); + } + + @Test + @DisplayName("applyToolChoice should keep required on kimi-k3") + void testToolChoiceRequiredKeptOnK3() { + OpenAIRequest request = requestWithTools("kimi-k3"); + + formatter.applyToolChoice(request, new ToolChoice.Required()); + + assertEquals("required", request.getToolChoice()); + } + + @Test + @DisplayName("applyOptions should strip fixed sampling params on kimi-* models") + void testStripFixedSamplingParams() { + OpenAIRequest request = + OpenAIRequest.builder().model("kimi-k2.6").messages(List.of()).build(); + + GenerateOptions options = + GenerateOptions.builder() + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.5) + .build(); + + formatter.applyOptions(request, options, null); + + assertNull(request.getTemperature()); + assertNull(request.getTopP()); + assertNull(request.getFrequencyPenalty()); + assertNull(request.getPresencePenalty()); + } + + @Test + @DisplayName("applyOptions should map max_completion_tokens and strip reasoning_effort") + void testSharedSanitizeBehavior() { + OpenAIRequest request = + OpenAIRequest.builder().model("kimi-k2.6").messages(List.of()).build(); + + GenerateOptions options = + GenerateOptions.builder() + .reasoningEffort("high") + .maxCompletionTokens(32768) + .build(); + + formatter.applyOptions(request, options, null); + + assertNull(request.getReasoningEffort()); + assertEquals(32768, request.getMaxTokens()); + assertNull(request.getMaxCompletionTokens()); + } + } +} diff --git a/docs/v2/en/docs/building-blocks/model.md b/docs/v2/en/docs/building-blocks/model.md index c34fb54882..d3ff81789f 100644 --- a/docs/v2/en/docs/building-blocks/model.md +++ b/docs/v2/en/docs/building-blocks/model.md @@ -73,6 +73,8 @@ ReActAgent agent = .build(); ``` +The extension module is discovered through Java SPI. The model provider reads its standard environment variables such as `DASHSCOPE_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `MOONSHOT_API_KEY` (for `kimi:` ids served by the OpenAI-compatible Kimi endpoint). Ollama reads `OLLAMA_BASE_URL` when present and otherwise defaults to the local Ollama endpoint. +The extension module is discovered through Java SPI. The model provider reads its standard environment variables such as `DASHSCOPE_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`. Ollama reads `OLLAMA_BASE_URL` when present and otherwise defaults to the local Ollama endpoint. The extension module is discovered through Java SPI. The model provider reads its standard environment variables such as `DASHSCOPE_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `GLM_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`. Ollama reads `OLLAMA_BASE_URL` when present and otherwise defaults to the local Ollama endpoint. ### Explicit model builder diff --git a/docs/v2/en/integration/model/openai.md b/docs/v2/en/integration/model/openai.md index 3aaa7b2d28..9807b98262 100644 --- a/docs/v2/en/integration/model/openai.md +++ b/docs/v2/en/integration/model/openai.md @@ -1,6 +1,6 @@ # OpenAI Model -`agentscope-extensions-model-openai` integrates OpenAI Chat Completions-style models. It is also the module to use for OpenAI-compatible endpoints such as DeepSeek, GLM, and similar services when their wire format follows the OpenAI API. +`agentscope-extensions-model-openai` integrates OpenAI Chat Completions-style models. It is also the module to use for OpenAI-compatible endpoints such as DeepSeek, GLM, Kimi, and similar services when their wire format follows the OpenAI API. ## Add the dependency @@ -39,6 +39,11 @@ OpenAIChatModel model = OpenAIChatModel.builder() For generic compatible endpoints, set `baseUrl(...)` and the model name expected by that service. For DeepSeek, prefer the dedicated `deepseek:` registry id so AgentScope applies the DeepSeek base URL, `DEEPSEEK_API_KEY`, and DeepSeek formatter defaults. See [DeepSeek](deepseek.md). +## Kimi (Moonshot AI) + +Kimi has a dedicated adapter in the `io.agentscope.extensions.model.openai.compat.kimi` package. It reuses `OpenAIChatModel` under the hood but is preconfigured for the Kimi open platform endpoint (`https://api.moonshot.cn/v1`). + +Set `MOONSHOT_API_KEY` (or `KIMI_API_KEY`), then use the `kimi:` id: ## GLM (Zhipu AI) GLM has a dedicated adapter in the `io.agentscope.extensions.model.openai.compat.glm` package. It reuses `OpenAIChatModel` under the hood but is preconfigured for the Zhipu open platform endpoint (`https://open.bigmodel.cn/api/paas/v4`). @@ -48,6 +53,21 @@ Set `GLM_API_KEY` (or `ZHIPUAI_API_KEY`), then use the `glm:` id: ```java ReActAgent agent = ReActAgent.builder() .name("assistant") + .model("kimi:kimi-k3") // Resolved by KimiModelProvider through ModelRegistry + .build(); +``` + +Or build the model explicitly with a Kimi formatter: + +```java +import io.agentscope.extensions.model.openai.OpenAIChatModel; +import io.agentscope.extensions.model.openai.compat.kimi.KimiFormatter; + +OpenAIChatModel model = OpenAIChatModel.builder() + .apiKey(System.getenv("MOONSHOT_API_KEY")) + .baseUrl("https://api.moonshot.cn/v1") + .modelName("kimi-k3") + .formatter(new KimiFormatter()) // or KimiMultiAgentFormatter for multi-agent prompts .model("glm:glm-5.2") // Resolved by GLMModelProvider through ModelRegistry .build(); ``` @@ -67,6 +87,22 @@ OpenAIChatModel model = OpenAIChatModel.builder() .build(); ``` +The Kimi formatters adapt requests to the latest official API: + +- On `kimi-*` models (kimi-k3, kimi-k2.7-code, kimi-k2.6, etc.), `temperature` / `top_p` / `frequency_penalty` / `presence_penalty` are fixed by the platform and the API rejects other values, so these parameters are stripped from requests; the `moonshot-v1` series still accepts them and they are passed through. +- `reasoning_effort` (`low` / `high` / `max`, default `max`) is only supported by `kimi-k3` and is stripped on other models; use the `thinking` body parameter on K2.x models instead. +- `thinking_budget` is not a Kimi parameter and is always stripped. +- Kimi only supports `max_tokens`: when only `max_completion_tokens` (the newer OpenAI style) is set, it is mapped to `max_tokens`. Note that reasoning tokens (`reasoning_content`) count towards `max_tokens`; the official guide recommends `max_tokens >= 16000` for thinking models. +- `tool_choice`: `auto` / `none` are supported by all models; `required` is only supported by `kimi-k3` and is degraded to `auto` on the K2.x series; forcing a specific function is incompatible with thinking enabled (HTTP 400), so it is degraded to `auto` on always-thinking models (`kimi-k3`, `kimi-k2.7-code`) and passed through otherwise (disable thinking via the `thinking` parameter first on `kimi-k2.6`). +- The `strict` parameter in tool definitions is not sent (not documented by Kimi). +- `reasoning_content` on assistant history messages is passed back as-is, satisfying Preserved Thinking on `kimi-k3` / `kimi-k2.7-code` and `thinking.keep = "all"` on `kimi-k2.6`. +- `KimiModelProvider` disables native structured output by default because the Kimi `response_format` only supports `json_object` (JSON Mode); agents fall back to the `generate_response` tool. It also disables structured output alongside tools by default, because Kimi prioritises `response_format` over tool invocations. Both can be re-enabled with the `nativeStructuredOutput` / `nativeStructuredOutputWithTools` options in `ModelCreationContext`. + +Thinking-related parameters: + +- `kimi-k2.6` / `kimi-k2.5` control thinking through the `thinking` body parameter, e.g. `GenerateOptions.additionalBodyParam("thinking", Map.of("type", "disabled"))`; `kimi-k2.6` also supports `Map.of("type", "enabled", "keep", "all")` to enable Preserved Thinking. +- `kimi-k2.7-code` always thinks and Preserved Thinking cannot be disabled; do not pass a `thinking` parameter. +- `kimi-k3` always thinks and uses the top-level `reasoning_effort` to configure thinking effort; use `GenerateOptions.reasoningEffort("high")`, which is passed through directly. The GLM formatters adapt requests to the latest Zhipu API: - Ensure at least one user message exists (the API returns error 1214 otherwise). diff --git a/docs/v2/zh/docs/building-blocks/model.md b/docs/v2/zh/docs/building-blocks/model.md index af207671d8..3de56c29fe 100644 --- a/docs/v2/zh/docs/building-blocks/model.md +++ b/docs/v2/zh/docs/building-blocks/model.md @@ -73,6 +73,8 @@ ReActAgent agent = .build(); ``` +扩展模块会通过 Java SPI 被自动发现。模型提供商会读取自己的标准环境变量,例如 `DASHSCOPE_API_KEY`、`OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、`GEMINI_API_KEY`、`MOONSHOT_API_KEY`(`kimi:` id 走 Kimi 的 OpenAI 兼容端点)。Ollama 会在存在时读取 `OLLAMA_BASE_URL`,否则默认使用本地 Ollama endpoint。 +扩展模块会通过 Java SPI 被自动发现。模型提供商会读取自己的标准环境变量,例如 `DASHSCOPE_API_KEY`、`OPENAI_API_KEY`、`DEEPSEEK_API_KEY`、`ANTHROPIC_API_KEY`、`GEMINI_API_KEY`。Ollama 会在存在时读取 `OLLAMA_BASE_URL`,否则默认使用本地 Ollama endpoint。 扩展模块会通过 Java SPI 被自动发现。模型提供商会读取自己的标准环境变量,例如 `DASHSCOPE_API_KEY`、`OPENAI_API_KEY`、`GLM_API_KEY`、`DEEPSEEK_API_KEY`、`ANTHROPIC_API_KEY`、`GEMINI_API_KEY`。Ollama 会在存在时读取 `OLLAMA_BASE_URL`,否则默认使用本地 Ollama endpoint。 ### 显式 Model builder diff --git a/docs/v2/zh/integration/model/openai.md b/docs/v2/zh/integration/model/openai.md index 6327fc4f6e..ff78c073c6 100644 --- a/docs/v2/zh/integration/model/openai.md +++ b/docs/v2/zh/integration/model/openai.md @@ -1,6 +1,6 @@ # OpenAI 模型 -`agentscope-extensions-model-openai` 接入 OpenAI Chat Completions 风格的模型。OpenAI 兼容端点也使用这个适配模块,例如 DeepSeek、GLM 等遵循 OpenAI API 载荷格式的服务。 +`agentscope-extensions-model-openai` 接入 OpenAI Chat Completions 风格的模型。OpenAI 兼容端点也使用这个适配模块,例如 DeepSeek、GLM、Kimi 等遵循 OpenAI API 载荷格式的服务。 ## 添加依赖 @@ -39,6 +39,11 @@ OpenAIChatModel model = OpenAIChatModel.builder() 接入通用兼容端点时,设置 `baseUrl(...)` 和该服务期望的模型名。DeepSeek 推荐使用专门的 `deepseek:` registry id,这样 AgentScope 会自动应用 DeepSeek base URL、`DEEPSEEK_API_KEY` 和 DeepSeek formatter 默认值。详见 [DeepSeek](deepseek.md)。 +## Kimi(月之暗面 / Moonshot AI) + +Kimi 在 `io.agentscope.extensions.model.openai.compat.kimi` 包中提供了专用适配。它底层复用 `OpenAIChatModel`,但默认配置为 Kimi 开放平台端点(`https://api.moonshot.cn/v1`)。 + +设置 `MOONSHOT_API_KEY`(或 `KIMI_API_KEY`)后,使用 `kimi:` 字符串 id: ## GLM(智谱 AI) GLM 在 `io.agentscope.extensions.model.openai.compat.glm` 包中提供了专用适配。它底层复用 `OpenAIChatModel`,但默认配置为智谱开放平台端点(`https://open.bigmodel.cn/api/paas/v4`)。 @@ -48,6 +53,21 @@ GLM 在 `io.agentscope.extensions.model.openai.compat.glm` 包中提供了专用 ```java ReActAgent agent = ReActAgent.builder() .name("assistant") + .model("kimi:kimi-k3") // 底层由 KimiModelProvider 通过 ModelRegistry 解析 + .build(); +``` + +也可以显式构建并指定 Kimi formatter: + +```java +import io.agentscope.extensions.model.openai.OpenAIChatModel; +import io.agentscope.extensions.model.openai.compat.kimi.KimiFormatter; + +OpenAIChatModel model = OpenAIChatModel.builder() + .apiKey(System.getenv("MOONSHOT_API_KEY")) + .baseUrl("https://api.moonshot.cn/v1") + .modelName("kimi-k3") + .formatter(new KimiFormatter()) // 多智能体提示词场景使用 KimiMultiAgentFormatter .model("glm:glm-5.2") // 底层由 GLMModelProvider 通过 ModelRegistry 解析 .build(); ``` @@ -67,6 +87,22 @@ OpenAIChatModel model = OpenAIChatModel.builder() .build(); ``` +Kimi formatter 按最新官方 API 对请求做了以下适配: + +- `kimi-*` 系列模型(kimi-k3、kimi-k2.7-code、kimi-k2.6 等)的 `temperature` / `top_p` / `frequency_penalty` / `presence_penalty` 由平台固定、传入其他值会报错,因此这些参数会从请求中移除;`moonshot-v1` 系列仍可修改,会正常透传。 +- `reasoning_effort`(`low` / `high` / `max`,默认 `max`)仅 `kimi-k3` 支持,其他模型上会移除;K2.x 模型请改用 `thinking` 请求体参数。 +- `thinking_budget` 不是 Kimi 参数,始终移除。 +- Kimi 仅支持 `max_tokens`:如果只设置了 `max_completion_tokens`(OpenAI 新风格),会自动映射为 `max_tokens`。注意思考内容(`reasoning_content`)也计入 `max_tokens`,官方建议思考模型设置 `max_tokens >= 16000`。 +- `tool_choice`:`auto` / `none` 全系支持;`required` 仅 `kimi-k3` 支持,K2.x 系列会降级为 `auto`;指定具体函数与思考开启不兼容(HTTP 400),在始终思考的模型(`kimi-k3`、`kimi-k2.7-code`)上会降级为 `auto`,其余模型正常透传(`kimi-k2.6` 上使用时请先通过 `thinking` 参数关闭思考)。 +- 工具定义中不发送 `strict` 参数(Kimi 文档未支持)。 +- assistant 历史消息中的 `reasoning_content` 会原样回传,满足 `kimi-k3` / `kimi-k2.7-code` 的保留式思考(Preserved Thinking)以及 `kimi-k2.6` 的 `thinking.keep = "all"` 要求。 +- `KimiModelProvider` 默认关闭原生结构化输出,因为 Kimi 的 `response_format` 仅支持 `json_object`(JSON Mode),agent 会自动改走 `generate_response` 工具;同时默认关闭"结构化输出与工具共存"(Kimi 会优先遵循 `response_format` 而跳过工具调用)。两者都可以在 `ModelCreationContext` 中通过 `nativeStructuredOutput` / `nativeStructuredOutputWithTools` 选项显式开启。 + +思考相关参数: + +- `kimi-k2.6` / `kimi-k2.5` 通过 `thinking` 请求体参数控制思考,例如 `GenerateOptions.additionalBodyParam("thinking", Map.of("type", "disabled"))`;`kimi-k2.6` 还支持 `Map.of("type", "enabled", "keep", "all")` 开启保留式思考。 +- `kimi-k2.7-code` 始终开启思考且保留式思考不可关闭,无需(也不应)传入 `thinking` 参数。 +- `kimi-k3` 始终思考,使用顶层 `reasoning_effort` 配置思考力度,直接用 `GenerateOptions.reasoningEffort("high")` 即可透传。 GLM formatter 按最新智谱 API 对请求做了以下适配: - 保证至少存在一条 user 消息(否则 API 返回错误码 1214)。