feat: support Kimi K3

This commit is contained in:
bingqilinweimaotai
2026-07-18 17:38:32 +08:00
committed by Xubin Ren
parent b76d54aae1
commit 8c68c6fe1e
6 changed files with 82 additions and 13 deletions
+19 -5
View File
@@ -59,6 +59,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
"X-OpenRouter-Title": "nanobot",
"X-OpenRouter-Categories": "cli-agent,personal-agent",
}
_KIMI_K3_MODEL = "kimi-k3"
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
"kimi-k2.5",
"kimi-k2.6",
@@ -113,9 +114,9 @@ def _provider_prefix_key(name: str) -> str:
def _requires_max_completion_tokens(model_name: str) -> bool:
"""Return True for models that reject ``max_tokens`` (GPT-5 family, o-series)."""
"""Return True for models that require ``max_completion_tokens``."""
slug = _model_slug(model_name)
return "gpt-5" in slug or any(
return slug == _KIMI_K3_MODEL or "gpt-5" in slug or any(
slug == p or slug.startswith((p + "-", p + ".")) for p in ("o1", "o3", "o4")
)
@@ -716,9 +717,12 @@ class OpenAICompatProvider(LLMProvider):
) -> bool:
"""Return True when the model accepts a temperature parameter.
GPT-5 family and reasoning models (o1/o3/o4) reject temperature
when reasoning_effort is set to anything other than ``"none"``.
Kimi K3 uses a fixed temperature that should be omitted. GPT-5 family
and reasoning models (o1/o3/o4) reject temperature when
reasoning_effort is set to anything other than ``"none"``.
"""
if _model_slug(model_name) == _KIMI_K3_MODEL:
return False
if reasoning_effort and reasoning_effort.lower() != "none":
return False
name = model_name.lower()
@@ -788,6 +792,17 @@ class OpenAICompatProvider(LLMProvider):
semantic_effort = "minimal"
wire_effort = reasoning_effort
slug = _model_slug(model_name)
if slug == _KIMI_K3_MODEL and semantic_effort is not None:
# K3 always reasons and currently accepts only the top-level
# reasoning_effort="max". Preserve disabled/default semantics by
# omitting the field; normalize older enabled presets to "max" so
# switching from a K2.x model does not send an unsupported value.
if semantic_effort in ("none", "minimal"):
wire_effort = None
else:
semantic_effort = "max"
wire_effort = "max"
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
wire_effort = "minimum"
@@ -825,7 +840,6 @@ class OpenAICompatProvider(LLMProvider):
# Only send thinking controls when reasoning_effort is explicit so
# omitting the config preserves each provider's default.
if reasoning_effort is not None:
slug = _model_slug(model_name)
thinking_enabled = semantic_effort not in ("none", "minimal")
for thinking_style in _thinking_styles_for(spec, model_name):
if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS:
+8 -2
View File
@@ -121,7 +121,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
"2:3",
"21:9",
}
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144}
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 1_048_576}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -634,7 +634,9 @@ def _parse_context_window_tokens(value: str | None) -> int | None:
except ValueError:
raise WebUISettingsError("context_window_tokens must be an integer") from None
if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS:
raise WebUISettingsError("context_window_tokens must be 65536, 200000, or 262144")
raise WebUISettingsError(
"context_window_tokens must be 65536, 200000, 262144, or 1048576"
)
return parsed
@@ -706,6 +708,10 @@ def _reasoning_effort_values_for(provider_name: str, model: str) -> list[str]:
return list(_DEFAULT_REASONING_EFFORT_VALUES)
model_lower = (model or "").lower()
if model_lower.rsplit("/", 1)[-1] == "kimi-k3":
# K3 always reasons and currently exposes only its default/max effort.
return ["", "max"]
implicit = getattr(spec, "implicit_reasoning_models", ())
if implicit and any(pat in model_lower for pat in implicit):
# Reasoning is always on; only "Default" makes sense.
+37
View File
@@ -1540,6 +1540,43 @@ def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
assert "extra_body" not in kw
def test_kimi_k3_uses_native_defaults() -> None:
"""K3 omits fixed sampling params and uses the non-deprecated token limit field."""
kw = _build_kwargs_for("moonshot", "kimi-k3", reasoning_effort=None)
assert "temperature" not in kw
assert "max_tokens" not in kw
assert kw["max_completion_tokens"] == 1024
assert "reasoning_effort" not in kw
assert "extra_body" not in kw
def test_kimi_k3_uses_top_level_max_reasoning_effort() -> None:
"""K3 uses top-level reasoning_effort=max, never the K2.x thinking body."""
kw = _build_kwargs_for("moonshot", "kimi-k3", reasoning_effort="max")
assert kw["reasoning_effort"] == "max"
assert "temperature" not in kw
assert "extra_body" not in kw
def test_kimi_k3_normalizes_legacy_enabled_reasoning_effort() -> None:
"""Switching a K2.x preset to K3 must not send unsupported effort values."""
kw = _build_kwargs_for("moonshot", "kimi-k3", reasoning_effort="high")
assert kw["reasoning_effort"] == "max"
assert "extra_body" not in kw
def test_kimi_k3_omits_disabled_reasoning_effort() -> None:
"""K3 cannot disable reasoning, so disabled effort falls back to its default."""
kw = _build_kwargs_for("moonshot", "kimi-k3", reasoning_effort="none")
assert "reasoning_effort" not in kw
assert "temperature" not in kw
assert "extra_body" not in kw
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
+9 -4
View File
@@ -15,6 +15,7 @@ from nanobot.webui.settings_api import (
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
_reasoning_effort_values_for,
create_model_configuration,
login_oauth_provider,
provider_models_payload,
@@ -40,6 +41,10 @@ def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3+editable") == "latest"
def test_kimi_k3_only_offers_supported_reasoning_effort_values() -> None:
assert _reasoning_effort_values_for("moonshot", "kimi-k3") == ["", "max"]
def test_settings_payload_includes_versioned_docs(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -357,13 +362,13 @@ def test_update_model_configuration_accepts_context_window_options(
payload = update_model_configuration(
{
"name": ["codex"],
"context_window_tokens": ["262144"],
"context_window_tokens": ["1048576"],
}
)
assert payload["agent"]["context_window_tokens"] == 262144
assert payload["agent"]["context_window_tokens"] == 1048576
saved = load_config(config_path)
assert saved.model_presets["codex"].context_window_tokens == 262144
assert saved.model_presets["codex"].context_window_tokens == 1048576
def test_update_context_window_rejects_unknown_values(
@@ -376,7 +381,7 @@ def test_update_context_window_rejects_unknown_values(
with pytest.raises(
WebUISettingsError,
match="context_window_tokens must be 65536, 200000, or 262144",
match="context_window_tokens must be 65536, 200000, 262144, or 1048576",
):
update_agent_settings({"context_window_tokens": ["128000"]})
@@ -222,7 +222,7 @@ type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144] as const;
const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 1_048_576] as const;
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
"aihubmix",
"atomic_chat",
@@ -2797,7 +2797,13 @@ function ModelsSettings({
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
value: String(tokens),
label:
tokens === 262_144 ? "256K" : tokens === 200_000 ? "200K" : "64K",
tokens === 1_048_576
? "1M"
: tokens === 262_144
? "256K"
: tokens === 200_000
? "200K"
: "64K",
}))}
onChange={(value) =>
setForm((prev) => ({
+1
View File
@@ -1602,6 +1602,7 @@ describe("SettingsView Apps catalog", () => {
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "1M" })).toBeInTheDocument();
});
it("keeps the default model distinct from the active named configuration", async () => {