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
This commit is contained in:
huidongchen148 2026-10-27 14:57:29 +08:00 committed by GitHub
parent 77d0fc9d69
commit 612140f183
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 346 additions and 1 deletions

View File

@ -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.

View File

@ -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),

View File

@ -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":

View File

@ -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

View File

@ -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)},

View File

@ -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