feat: support Kimi K3
This commit is contained in:
committed by
Xubin Ren
parent
b76d54aae1
commit
8c68c6fe1e
@@ -59,6 +59,7 @@ _DEFAULT_OPENROUTER_HEADERS = {
|
|||||||
"X-OpenRouter-Title": "nanobot",
|
"X-OpenRouter-Title": "nanobot",
|
||||||
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
||||||
}
|
}
|
||||||
|
_KIMI_K3_MODEL = "kimi-k3"
|
||||||
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||||
"kimi-k2.5",
|
"kimi-k2.5",
|
||||||
"kimi-k2.6",
|
"kimi-k2.6",
|
||||||
@@ -113,9 +114,9 @@ def _provider_prefix_key(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _requires_max_completion_tokens(model_name: str) -> bool:
|
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)
|
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")
|
slug == p or slug.startswith((p + "-", p + ".")) for p in ("o1", "o3", "o4")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -716,9 +717,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when the model accepts a temperature parameter.
|
"""Return True when the model accepts a temperature parameter.
|
||||||
|
|
||||||
GPT-5 family and reasoning models (o1/o3/o4) reject temperature
|
Kimi K3 uses a fixed temperature that should be omitted. GPT-5 family
|
||||||
when reasoning_effort is set to anything other than ``"none"``.
|
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":
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
return False
|
return False
|
||||||
name = model_name.lower()
|
name = model_name.lower()
|
||||||
@@ -788,6 +792,17 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
semantic_effort = "minimal"
|
semantic_effort = "minimal"
|
||||||
|
|
||||||
wire_effort = reasoning_effort
|
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":
|
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
|
||||||
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
||||||
wire_effort = "minimum"
|
wire_effort = "minimum"
|
||||||
@@ -825,7 +840,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# Only send thinking controls when reasoning_effort is explicit so
|
# Only send thinking controls when reasoning_effort is explicit so
|
||||||
# omitting the config preserves each provider's default.
|
# omitting the config preserves each provider's default.
|
||||||
if reasoning_effort is not None:
|
if reasoning_effort is not None:
|
||||||
slug = _model_slug(model_name)
|
|
||||||
thinking_enabled = semantic_effort not in ("none", "minimal")
|
thinking_enabled = semantic_effort not in ("none", "minimal")
|
||||||
for thinking_style in _thinking_styles_for(spec, model_name):
|
for thinking_style in _thinking_styles_for(spec, model_name):
|
||||||
if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS:
|
if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS:
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
"2:3",
|
"2:3",
|
||||||
"21:9",
|
"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_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-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:
|
except ValueError:
|
||||||
raise WebUISettingsError("context_window_tokens must be an integer") from None
|
raise WebUISettingsError("context_window_tokens must be an integer") from None
|
||||||
if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS:
|
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
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@@ -706,6 +708,10 @@ def _reasoning_effort_values_for(provider_name: str, model: str) -> list[str]:
|
|||||||
return list(_DEFAULT_REASONING_EFFORT_VALUES)
|
return list(_DEFAULT_REASONING_EFFORT_VALUES)
|
||||||
|
|
||||||
model_lower = (model or "").lower()
|
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", ())
|
implicit = getattr(spec, "implicit_reasoning_models", ())
|
||||||
if implicit and any(pat in model_lower for pat in implicit):
|
if implicit and any(pat in model_lower for pat in implicit):
|
||||||
# Reasoning is always on; only "Default" makes sense.
|
# Reasoning is always on; only "Default" makes sense.
|
||||||
|
|||||||
@@ -1540,6 +1540,43 @@ def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
|
|||||||
assert "extra_body" not in kw
|
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:
|
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
|
||||||
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
|
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from nanobot.webui.settings_api import (
|
|||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
|
_reasoning_effort_values_for,
|
||||||
create_model_configuration,
|
create_model_configuration,
|
||||||
login_oauth_provider,
|
login_oauth_provider,
|
||||||
provider_models_payload,
|
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"
|
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(
|
def test_settings_payload_includes_versioned_docs(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -357,13 +362,13 @@ def test_update_model_configuration_accepts_context_window_options(
|
|||||||
payload = update_model_configuration(
|
payload = update_model_configuration(
|
||||||
{
|
{
|
||||||
"name": ["codex"],
|
"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)
|
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(
|
def test_update_context_window_rejects_unknown_values(
|
||||||
@@ -376,7 +381,7 @@ def test_update_context_window_rejects_unknown_values(
|
|||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
WebUISettingsError,
|
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"]})
|
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 ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
|
||||||
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
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([
|
const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
|
||||||
"aihubmix",
|
"aihubmix",
|
||||||
"atomic_chat",
|
"atomic_chat",
|
||||||
@@ -2797,7 +2797,13 @@ function ModelsSettings({
|
|||||||
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
|
options={CONTEXT_WINDOW_TOKEN_OPTIONS.map((tokens) => ({
|
||||||
value: String(tokens),
|
value: String(tokens),
|
||||||
label:
|
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) =>
|
onChange={(value) =>
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
|
|||||||
@@ -1602,6 +1602,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "200K" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "256K" })).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 () => {
|
it("keeps the default model distinct from the active named configuration", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user