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