From 8c68c6fe1e306dcdc5270ab21e67449f35275f9d Mon Sep 17 00:00:00 2001 From: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:57:34 +0800 Subject: [PATCH] feat: support Kimi K3 --- nanobot/providers/openai_compat_provider.py | 24 +++++++++--- nanobot/webui/settings_api.py | 10 ++++- tests/providers/test_litellm_kwargs.py | 37 +++++++++++++++++++ tests/webui/test_settings_api.py | 13 +++++-- .../src/components/settings/SettingsView.tsx | 10 ++++- webui/src/tests/settings-view.test.tsx | 1 + 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 10a17d11..262900e3 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -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: diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 59a11e49..2be1d131 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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. diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index f9f4fd6a..cd5c3770 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -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. diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 299b4af2..b20366bd 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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"]}) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 8956f282..6615d572 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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) => ({ diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 714d934d..ebef1684 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -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 () => {