From 612140f183b6f4f592690696a8b83a12c4adcae5 Mon Sep 17 00:00:00 2001 From: huidongchen148 Date: Tue, 27 Oct 2026 14:57:29 +0800 Subject: [PATCH] feat(models): add managed Kimi K3.1 Pro capabilities (#7566) Declare required thinking, native reasoning effort and image support for both K3.1 Pro IDs. Normalize fixed parameters at the final Moonshot request boundary and cover real SDK serialization with offline regression tests. Fixes #7425 --- README.md | 8 + backend/docs/CONFIGURATION.md | 20 ++ .../config/managed_model_providers.py | 6 + .../harness/deerflow/models/AGENTS.md | 9 + .../deerflow/models/patched_deepseek.py | 33 ++- backend/tests/test_managed_moonshot.py | 271 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_managed_moonshot.py diff --git a/README.md b/README.md index cde70478cd..fc088eeb30 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,14 @@ fences, including unclosed fences, in complete and streamed replies. Newly saved models are enabled and immediately refresh existing chat and Agent model selectors, without editing YAML or restarting the Gateway. + The Moonshot (China) preset recognizes `kimi-k3.1-pro` and + `kimi-k3.1-pro-highspeed` with required thinking, image input and native + `low` / `high` / `max` reasoning effort (default `max`). Thinking stays on + even when a caller requests it off. Requests to the official endpoint omit + the unsupported thinking switch and fixed sampling parameters. See the + [model registration reference](backend/docs/CONFIGURATION.md#models-registered-through-the-web) + for the image-input and parameter constraints. + **Custom OpenAI-compatible** still supports existing/custom base URLs and unauthenticated endpoints. **Advanced settings** retains display name, token limits and vision overrides; unknown IDs do not receive invented capabilities. diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index d7edaba106..ffd724b1a5 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -342,6 +342,26 @@ Plan from the model name. Plans/regions without a preset can use the custom OpenAI-compatible option if their documented protocol is compatible; native protocols need a verified backend preset or operator YAML configuration. +The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and +`kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history, +and accept top-level `reasoning_effort` values `low`, `high` and `max`, with +`max` as the default. The existing reasoning contract keeps thinking enabled +when a caller requests it off. Unsupported effort values use the default; +these models do not declare aliases for the generic effort vocabulary. + +For these two models at the official `https://api.moonshot.cn/v1` endpoint, +`PatchedChatDeepSeek` omits `thinking` and the fixed sampling parameters +`temperature`, `top_p`, `n`, `presence_penalty` and `frequency_penalty` from +the final request, including values supplied through `extra_body`. Moonshot +uses its fixed values (respectively `1.0`, `0.95`, `1`, `0`, `0`). This rule +also applies to explicit YAML profiles using that adapter and endpoint; +Kimi K2.5, unknown model IDs and custom endpoints retain their existing behavior. +Image capability covers base64/data-URL images and provider file IDs, not +public image URLs; DeerFlow's image middleware uses base64. This declaration +does not add video UI support. See the official +[K3.1 quickstart](https://platform.kimi.com/docs/guide/kimi-k3-1-pro-quickstart) +and [model parameter reference](https://platform.kimi.com/docs/api/models-overview). + Endpoint/auth sources used for these presets include the official [OpenAI SDK](https://github.com/openai/openai-python/blob/main/src/openai/_client.py), [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_client.py), diff --git a/backend/packages/harness/deerflow/config/managed_model_providers.py b/backend/packages/harness/deerflow/config/managed_model_providers.py index 14ffa42a8b..9e342aa201 100644 --- a/backend/packages/harness/deerflow/config/managed_model_providers.py +++ b/backend/packages/harness/deerflow/config/managed_model_providers.py @@ -12,6 +12,7 @@ from urllib.parse import urlsplit _OPENAI = "langchain_openai:ChatOpenAI" _DEEPSEEK = "deerflow.models.patched_deepseek:PatchedChatDeepSeek" _MINIMAX = "deerflow.models.patched_minimax:PatchedChatMiniMax" +MOONSHOT_K31_MODELS = frozenset({"kimi-k3.1-pro", "kimi-k3.1-pro-highspeed"}) @dataclass(frozen=True) @@ -117,6 +118,11 @@ def _capabilities(provider: str, model: str) -> dict[str, Any]: when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}, "temperature": 1.0}, when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}, "temperature": 0.6}, ) + elif provider == "moonshot-cn" and model in MOONSHOT_K31_MODELS: + result.update( + supports_vision=True, + reasoning={"thinking": "required", "dialect": "none", "history": "preserve", "effort": {"values": ["low", "high", "max"], "default": "max"}}, + ) elif provider in {"minimax", "minimax-cn"} and model in {"MiniMax-M2.7", "MiniMax-M2.7-highspeed"}: result["reasoning"] = {"thinking": "required", "dialect": "none", "history": "preserve"} elif provider in {"minimax", "minimax-cn"} and model == "MiniMax-M3": diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index a6f8902f93..46c24911d8 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -243,6 +243,15 @@ never infer that adapter from a model-name substring. Reads do not migrate the catalog. New names are allocated under the existing catalog lock with bounded ASCII slugs and random suffixes; edits preserve names and references. +Moonshot's two exact K3.1 Pro IDs use required thinking, `dialect=none`, preserved +history and native `low/high/max` effort (default `max`, no aliases). The shared +DeepSeek adapter gates final request normalization on the official Moonshot +endpoint and effective request model, including an `extra_body.model` override. +Omit the unsupported `thinking` switch and fixed sampling keys at both payload +levels without mutating caller mappings; the SDK merges `extra_body` last. +Keep K2.5, unknown IDs and other endpoints unchanged. Offline SDK coverage: +`tests/test_managed_moonshot.py`; live acceptance is a separate validation. + The admin-only `/api/managed-models/providers` exposes form metadata, not routing. Full updates require the current revision. Omitted keys are retained only for an unchanged provider/custom endpoint, an empty string clears the key and password diff --git a/backend/packages/harness/deerflow/models/patched_deepseek.py b/backend/packages/harness/deerflow/models/patched_deepseek.py index a47c355962..69384f0ed3 100644 --- a/backend/packages/harness/deerflow/models/patched_deepseek.py +++ b/backend/packages/harness/deerflow/models/patched_deepseek.py @@ -7,7 +7,9 @@ subsequent API calls, which causes errors with APIs that require reasoning_conte on all assistant messages when thinking mode is enabled. """ +from collections.abc import Mapping from typing import Any +from urllib.parse import urlsplit import openai from langchain_core.language_models import LanguageModelInput @@ -15,9 +17,34 @@ from langchain_core.messages import AIMessage from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_deepseek import ChatDeepSeek +from deerflow.config.managed_model_providers import MOONSHOT_K31_MODELS from deerflow.models.assistant_payload_replay import restore_assistant_payloads, restore_reasoning_content +def _is_moonshot_k31_request(api_base: str | None, payload: dict[str, Any]) -> bool: + """Match the official endpoint and model that the SDK will actually send.""" + extra_body = payload.get("extra_body") + model = extra_body.get("model", payload.get("model")) if isinstance(extra_body, Mapping) else payload.get("model") + if not isinstance(model, str) or model not in MOONSHOT_K31_MODELS: + return False + try: + endpoint = urlsplit(api_base or "") + return endpoint.scheme == "https" and endpoint.hostname == "api.moonshot.cn" and endpoint.port in (None, 443) and endpoint.path.rstrip("/") == "/v1" + except ValueError: + return False + + +def _normalize_moonshot_k31_payload(payload: dict[str, Any]) -> None: + """Let K3.1 supply its fixed sampling values; it accepts no thinking switch.""" + omitted = {"thinking", "temperature", "top_p", "n", "presence_penalty", "frequency_penalty"} + for key in omitted: + payload.pop(key, None) + extra_body = payload.get("extra_body") + if isinstance(extra_body, Mapping): + # The SDK merges extra_body last. Copy it to preserve caller-owned values. + payload["extra_body"] = {key: value for key, value in extra_body.items() if key not in omitted} + + def _thinking_enabled(*sources: Any) -> bool: """Return whether the request explicitly enables DeepSeek thinking mode.""" for source in sources: @@ -86,7 +113,11 @@ class PatchedChatDeepSeek(ChatDeepSeek): # Call parent to get the base payload payload = super()._get_request_payload(request_messages, stop=stop, **kwargs) - request_thinking_enabled = _thinking_enabled( + moonshot_k31 = _is_moonshot_k31_request(self.api_base, payload) + if moonshot_k31: + _normalize_moonshot_k31_payload(payload) + + request_thinking_enabled = moonshot_k31 or _thinking_enabled( payload, kwargs, {"extra_body": getattr(self, "extra_body", None)}, diff --git a/backend/tests/test_managed_moonshot.py b/backend/tests/test_managed_moonshot.py new file mode 100644 index 0000000000..2d36b34ad7 --- /dev/null +++ b/backend/tests/test_managed_moonshot.py @@ -0,0 +1,271 @@ +"""Managed Kimi K3.1 contracts through real LangChain/OpenAI HTTP serialization. + +Only HTTP transports are replaced; no provider credentials or network are used. +""" + +import json +from copy import deepcopy +from types import MappingProxyType, SimpleNamespace + +import httpx +import pytest +import pytest_asyncio +from langchain_core.messages import HumanMessage, ToolMessage + +from deerflow.config.app_config import AppConfig +from deerflow.config.managed_models import ManagedModel +from deerflow.models.factory import create_chat_model +from deerflow.models.patched_deepseek import PatchedChatDeepSeek +from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request + +_MODELS = ("kimi-k3.1-pro", "kimi-k3.1-pro-highspeed") +_OMITTED = {"temperature", "top_p", "n", "presence_penalty", "frequency_penalty", "thinking"} +_TOOL = {"type": "function", "function": {"name": "inspect_image", "description": "Inspect the image", "parameters": {"type": "object", "properties": {}}}} +_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7AAAAABJRU5ErkJggg==" + + +def _profile(model_id): + return ManagedModel(name="kimi-test", provider="moonshot-cn", model=model_id, api_key="offline-test-key").runtime_config() + + +@pytest_asyncio.fixture +async def wire(): + state = SimpleNamespace(requests=[], tool_turn=False) + + def respond(request): + body = json.loads(request.content) + state.requests.append((request, body)) + message = {"role": "assistant", "content": "OK", "reasoning_content": "Checked the image."} + finish = "stop" + if state.tool_turn and body["messages"][-1]["role"] != "tool": + message["content"] = "" + message["tool_calls"] = [{"id": "call-image", "type": "function", "function": {"name": "inspect_image", "arguments": "{}"}}] + finish = "tool_calls" + common = {"id": "chat-moonshot", "created": 1, "model": body["model"]} + if not body.get("stream"): + return httpx.Response(200, json={**common, "object": "chat.completion", "choices": [{"index": 0, "message": message, "finish_reason": finish}]}) + delta = deepcopy(message) + for index, call in enumerate(delta.get("tool_calls", [])): + call["index"] = index + chunks = [ + {**common, "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": delta, "finish_reason": None}]}, + {**common, "object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {}, "finish_reason": finish}]}, + ] + content = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n" + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=content.encode()) + + with httpx.Client(transport=httpx.MockTransport(respond)) as sync_client: + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as async_client: + state.clients = {"http_client": sync_client, "http_async_client": async_client} + yield state + + +def _model(wire, model_id=_MODELS[0], *, endpoint=None, **kwargs): + profile = _profile(model_id) + if endpoint is not None: + profile.api_base = endpoint + app_config = AppConfig.model_validate({"sandbox": {"use": "test"}, "models": [profile]}) + return create_chat_model(profile.name, app_config=app_config, attach_tracing=False, max_retries=0, **wire.clients, **kwargs) + + +async def _call(model, mode, messages=None, **kwargs): + messages = messages or [HumanMessage(content="Hello")] + if mode == "invoke": + return model.invoke(messages, **kwargs) + if mode == "ainvoke": + return await model.ainvoke(messages, **kwargs) + result = None + if mode == "stream": + for chunk in model.stream(messages, **kwargs): + result = chunk if result is None else result + chunk + else: + async for chunk in model.astream(messages, **kwargs): + result = chunk if result is None else result + chunk + assert result is not None + return result + + +@pytest.mark.parametrize("model_id", _MODELS) +def test_exact_presets_declare_required_thinking_and_native_effort(model_id): + profile = _profile(model_id) + assert profile.use == "deerflow.models.patched_deepseek:PatchedChatDeepSeek" + assert profile.supports_vision and profile.supports_thinking and profile.supports_reasoning_effort + contract = resolve_reasoning_contract(profile) + assert (contract.thinking, contract.dialect, contract.history) == ("required", "none", "preserve") + assert contract.effort.values == ("low", "high", "max") + assert contract.effort.default == "max" + assert dict(contract.effort.aliases) == {} + resolved = resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort=None) + assert resolved.thinking_enabled and resolved.reasoning_effort == "max" + assert "thinking_forced_on" in resolved.adjustments + assert profile.when_thinking_disabled is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id", _MODELS) +@pytest.mark.parametrize("mode", ["invoke", "ainvoke", "stream", "astream"]) +async def test_all_public_paths_send_native_defaults(wire, model_id, mode): + model = _model(wire, model_id, thinking_enabled=False) + assert isinstance(model, PatchedChatDeepSeek) + assert (await _call(model, mode)).content == "OK" + assert len(wire.requests) == 1 + request, body = wire.requests[0] + assert str(request.url) == "https://api.moonshot.cn/v1/chat/completions" + assert request.headers["authorization"] == "Bearer offline-test-key" + assert body["model"] == model_id + assert body["reasoning_effort"] == "max" + assert _OMITTED.isdisjoint(body) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id", _MODELS) +@pytest.mark.parametrize("effort,expected", [("low", "low"), ("high", "high"), ("max", "max"), ("minimal", "max"), ("medium", "max"), ("xhigh", "max")]) +async def test_factory_effort_uses_native_vocabulary_and_contract_fallback(wire, model_id, effort, expected): + model = _model(wire, model_id, reasoning_effort=effort) + await _call(model, "ainvoke") + assert wire.requests[0][1]["reasoning_effort"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["invoke", "astream"]) +@pytest.mark.parametrize("layer", ["factory", "bind", "invoke"]) +async def test_fixed_provider_parameters_cannot_be_reintroduced(wire, mode, layer): + overrides = { + "temperature": 0.2, + "top_p": 0.1, + "n": 1, + "presence_penalty": 1.0, + "frequency_penalty": 1.0, + "extra_body": { + "temperature": 0.3, + "top_p": 0.4, + "n": 3, + "presence_penalty": 0.9, + "frequency_penalty": 0.8, + "thinking": {"type": "disabled"}, + "metadata": {"fixture": "preserved"}, + }, + } + original = deepcopy(overrides) + model = _model(wire, model_overrides=overrides if layer == "factory" else None) + if layer == "bind": + model = model.bind(**overrides) + await _call(model, mode, **(overrides if layer == "invoke" else {})) + body = wire.requests[0][1] + assert _OMITTED.isdisjoint(body) + assert body["metadata"] == {"fixture": "preserved"} + assert overrides == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize("thinking_type", ["enabled", "disabled", "keep"]) +async def test_thinking_is_omitted_for_every_explicit_switch(wire, thinking_type): + extra = {"thinking": {"type": thinking_type}} + await _call(_model(wire), "ainvoke", extra_body=extra) + assert "thinking" not in wire.requests[0][1] + assert extra == {"thinking": {"type": thinking_type}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id,mode", [("kimi-k3.1-pro", "invoke"), ("kimi-k3.1-pro-highspeed", "astream")]) +async def test_direct_adapter_constructor_and_model_kwargs_are_finalized(wire, model_id, mode): + extra = {"temperature": 0.3, "n": 3, "thinking": {"type": "enabled"}, "metadata": {"fixture": "constructor"}} + model_kwargs = {"thinking": {"type": "disabled"}, "max_completion_tokens": 128} + original = deepcopy((extra, model_kwargs)) + model = PatchedChatDeepSeek( + model=model_id, + api_key="offline-test-key", + api_base="https://api.moonshot.cn/v1", + temperature=0.2, + top_p=0.1, + n=1, + presence_penalty=0.9, + frequency_penalty=0.8, + extra_body=extra, + model_kwargs=model_kwargs, + max_retries=0, + **wire.clients, + ) + assert (await _call(model, mode)).content == "OK" + body = wire.requests[0][1] + assert _OMITTED.isdisjoint(body) + assert body["metadata"] == {"fixture": "constructor"} + assert body["max_completion_tokens"] == 128 + assert (extra, model_kwargs) == original + + +@pytest.mark.asyncio +async def test_immutable_extra_body_mapping_uses_effective_model_without_mutation(wire): + values = {"model": "kimi-k3.1-pro-highspeed", "temperature": 0.2, "thinking": {"type": "disabled"}, "metadata": {"fixture": "mapping"}} + original = deepcopy(values) + await _call(_model(wire, "kimi-k2.5"), "ainvoke", extra_body=MappingProxyType(values)) + body = wire.requests[0][1] + assert body["model"] == "kimi-k3.1-pro-highspeed" + assert _OMITTED.isdisjoint(body) + assert body["metadata"] == {"fixture": "mapping"} + assert values == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize("endpoint", ["https://api.moonshot.cn/v1", "https://API.MOONSHOT.CN:443/v1/"]) +async def test_official_endpoint_forms_share_finalization(wire, endpoint): + await _call(_model(wire, endpoint=endpoint), "ainvoke", temperature=0.2) + assert "temperature" not in wire.requests[0][1] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("endpoint", ["https://proxy.example/v1", "http://api.moonshot.cn/v1", "https://api.moonshot.cn:8443/v1", "https://api.moonshot.cn/v2"]) +async def test_nonofficial_endpoints_are_not_rewritten(wire, endpoint): + await _call(_model(wire, endpoint=endpoint), "ainvoke", temperature=0.2, extra_body={"thinking": {"type": "disabled"}}) + body = wire.requests[0][1] + assert body["temperature"] == 0.2 + assert body["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id", ["kimi-k2.5", "unknown", "kimi-k3.1-pro-preview"]) +async def test_other_ids_keep_existing_capabilities_and_wire_settings(wire, model_id): + profile = _profile(model_id) + assert profile.reasoning.thinking == ("optional" if model_id == "kimi-k2.5" else "unsupported") + assert profile.supports_vision == (model_id == "kimi-k2.5") + await _call(_model(wire, model_id), "ainvoke", temperature=0.2) + assert wire.requests[0][1]["temperature"] == 0.2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("configured,effective,normalized", [("kimi-k3.1-pro", "kimi-k2.5", False), ("kimi-k2.5", "kimi-k3.1-pro", True), ("kimi-k3.1-pro", "unknown", False)]) +async def test_extra_body_model_override_determines_the_effective_contract(wire, configured, effective, normalized): + extra = {"model": effective, "temperature": 0.2, "thinking": {"type": "disabled"}, "metadata": {"fixture": "unchanged"}} + original = deepcopy(extra) + await _call(_model(wire, configured), "ainvoke", extra_body=extra) + body = wire.requests[0][1] + assert body["model"] == effective + assert body["metadata"] == {"fixture": "unchanged"} + assert ("temperature" not in body) == normalized + assert ("thinking" not in body) == normalized + if normalized: + assert body.get("reasoning_effort", "max") == "max" + assert extra == original + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_id", _MODELS) +async def test_image_tool_followup_replays_real_reasoning(wire, model_id): + wire.tool_turn = True + model = _model(wire, model_id, reasoning_effort="low").bind_tools([_TOOL]) + messages = [HumanMessage(content=[{"type": "text", "text": "Inspect this image."}, {"type": "image_url", "image_url": {"url": _IMAGE}}])] + original = deepcopy(messages) + first = await _call(model, "astream", messages) + assert first.additional_kwargs["reasoning_content"] == "Checked the image." + assert first.tool_calls[0]["name"] == "inspect_image" + history = [*messages, first, ToolMessage(content="One pixel", tool_call_id=first.tool_calls[0]["id"])] + assert (await _call(model, "ainvoke", history)).content == "OK" + assert len(wire.requests) == 2 + for _, body in wire.requests: + assert body["messages"][0]["content"][1]["image_url"]["url"] == _IMAGE + assert body["reasoning_effort"] == "low" + assert _OMITTED.isdisjoint(body) + assistant = wire.requests[1][1]["messages"][1] + assert assistant["reasoning_content"] == "Checked the image." + assert assistant["tool_calls"][0]["id"] == first.tool_calls[0]["id"] + assert messages == original