diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fc5503e41117..a7f13ba2d777 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -457,7 +457,8 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool: # Model-name prefixes that identify the Kimi / Moonshot family. Covers # - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k`` -# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...`` +# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``, +# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants) # Matched case-insensitively against the post-``normalize_model_name`` form, # so a caller's ``provider/vendor/model`` slug is handled the same as a # bare name. @@ -467,8 +468,14 @@ _KIMI_FAMILY_MODEL_PREFIXES = ( "k1.", "k1-", "k2.", "k2-", "k25", "k2.5", + "k3.", "k3-", ) +# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 +# as the exact slug ``k3``). Kept exact-match so unrelated model names that +# merely start with the same characters don't get misclassified. +_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"}) + def _model_name_is_kimi_family(model: str | None) -> bool: if not isinstance(model, str): @@ -479,6 +486,8 @@ def _model_name_is_kimi_family(model: str | None) -> bool: # Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``) if "/" in m: m = m.rsplit("/", 1)[-1] + if m in _KIMI_FAMILY_EXACT_SLUGS: + return True return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 98a559bfdf40..8c56cdf3c6a8 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -319,10 +319,12 @@ DEFAULT_CONTEXT_LENGTHS = { "grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast "grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest "grok": 131072, # catch-all (grok-beta, unknown grok-*) - # Kimi — K3 ships with a 1M context window (verified: platform.kimi.ai/docs/overview). - # Longest-key-first substring matching ensures "kimi-k3" resolves to 1M - # while older/unknown Kimi models still hit the generic 256K fallback. - "kimi-k3": 1_000_000, + # Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against + # models.dev and OpenRouter live metadata, matching the endpoint-scoped + # override in _endpoint_scoped_context_length). Longest-key-first substring + # matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models + # still hit the generic 256K fallback. + "kimi-k3": 1_048_576, "kimi": 262144, # Upstage Solar — api.upstage.ai/v1/models does not return context_length, # so these fallbacks keep token budgeting / compression from probing down diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index 206ccee16531..a7629c3f5dcb 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -232,6 +232,10 @@ def is_moonshot_model(model: str | None) -> bool: tail = bare.rsplit("/", 1)[-1] if tail.startswith("kimi-") or tail == "kimi": return True + # Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated / + # suffixed variants like ``k3.1`` or ``k3-turbo``). + if tail == "k3" or tail.startswith(("k3.", "k3-")): + return True # Vendor-prefixed forms commonly used on aggregators if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"): return True diff --git a/contributors/emails/githubespresso407@users.noreply.github.com b/contributors/emails/githubespresso407@users.noreply.github.com new file mode 100644 index 000000000000..9a4ca8c9201d --- /dev/null +++ b/contributors/emails/githubespresso407@users.noreply.github.com @@ -0,0 +1 @@ +githubespresso407 diff --git a/contributors/emails/info@datachainsystems.com b/contributors/emails/info@datachainsystems.com new file mode 100644 index 000000000000..4a3f3f085632 --- /dev/null +++ b/contributors/emails/info@datachainsystems.com @@ -0,0 +1 @@ +datachainsystems diff --git a/contributors/emails/punyko8@users.noreply.github.com b/contributors/emails/punyko8@users.noreply.github.com new file mode 100644 index 000000000000..7c3f6a04ab52 --- /dev/null +++ b/contributors/emails/punyko8@users.noreply.github.com @@ -0,0 +1 @@ +Punyko8 diff --git a/hermes_cli/models.py b/hermes_cli/models.py index b0356c3c61ab..dbb14e05f684 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -319,6 +319,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "minimaxai/minimax-m3", ], "kimi-coding": [ + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", @@ -344,6 +345,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "step-3.5-flash-2603", ], "moonshot": [ + "kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", @@ -466,6 +468,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "nemotron-3-ultra-free", ], "opencode-go": [ + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8754f067d787..0d1a154811b4 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -100,15 +100,15 @@ _DEFAULT_PROVIDER_MODELS = { "google/gemini-2.5-pro", "google/gemini-2.5-flash", ], "zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], - "kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], - "kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], + "kimi-coding": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], + "kimi-coding-cn": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "stepfun": ["step-3.5-flash", "step-3.5-flash-2603"], "arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"], "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-5", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"], - "opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"], + "opencode-go": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"], "huggingface": [ "Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528", diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 7fb250294cc2..982defc00a38 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1631,6 +1631,22 @@ class TestBuildAnthropicKwargs: assert _supports_xhigh_effort(m) is True, m assert _forbids_sampling_params(m) is False, m + def test_bare_k3_coding_plan_slug_is_kimi_family(self): + """Kimi Coding Plan serves K3 as the bare slug ``k3`` — it must be + classified as Kimi family (adaptive thinking) even on proxied + endpoints where only the model name is available. Lookalike + non-Kimi names must NOT match the exact-slug rule.""" + from agent.anthropic_adapter import ( + _model_name_is_kimi_family, + _supports_adaptive_thinking, + ) + for m in ("k3", "K3", "moonshotai/k3", "k3.1-preview", "k3-turbo"): + assert _model_name_is_kimi_family(m) is True, m + assert _supports_adaptive_thinking("k3") is True + # Prefix-lookalikes without a separator must not be swept in. + for m in ("k30", "k3000-chat", "keras-3"): + assert _model_name_is_kimi_family(m) is False, m + def test_fast_mode_omitted_for_unsupported_model(self): """fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s).""" kwargs = build_anthropic_kwargs( diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 1789aa5591df..4b0b327682f2 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -175,7 +175,15 @@ class TestEstimateRequestTokensRough: class TestDefaultContextLengths: def test_k3_context_is_scoped_to_confirmed_coding_endpoint(self): - """K3's 1 Mi context must not leak to unverified Moonshot endpoints.""" + """The bare ``k3`` slug's 1 Mi context must not leak to unverified endpoints. + + The named ``kimi-k3`` / ``kimi-k3-cot`` slugs resolve to 1 Mi + EVERYWHERE via DEFAULT_CONTEXT_LENGTHS — the window is a property of + the model, served at 1M on api.moonshot.ai and api.moonshot.cn alike + (verified against models.dev + OpenRouter live metadata). Only the + bare ``k3`` slug, which exists solely on the Kimi Coding Plan + endpoint, stays endpoint-scoped. + """ with patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.fetch_model_metadata", return_value={}), \ patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \ @@ -205,10 +213,16 @@ class TestDefaultContextLengths: ) == 1_048_576 for base_url in rejected_urls: - for model in ("k3", "kimi-k3", "kimi-k3-cot"): + # Bare slug: endpoint-scoped, must NOT leak off-endpoint. + assert get_model_context_length( + "k3", provider="kimi-coding", base_url=base_url + ) != 1_048_576 + # Named slugs: global DEFAULT_CONTEXT_LENGTHS entry applies + # everywhere the model is actually named kimi-k3. + for model in ("kimi-k3", "kimi-k3-cot"): assert get_model_context_length( model, provider="kimi-coding", base_url=base_url - ) != 1_048_576 + ) == 1_048_576 def test_grok_substring_matching(self): # Longest-first substring matching must resolve the real xAI model @@ -335,24 +349,25 @@ class TestDefaultContextLengths: def test_kimi_k3_context_1m(self): """Kimi K3 must resolve to 1M, not the generic Kimi fallback of 256K. - Context window verified against platform.kimi.ai/docs/overview - (2026-07). Kimi K3 is the current flagship model with a 1M-token - context window. + Context window verified against models.dev and OpenRouter live + metadata (1,048,576; 2026-07). Kimi K3 is the current flagship model + with a 1M-token context window — the value matches the + endpoint-scoped override in _endpoint_scoped_context_length. """ from agent.model_metadata import get_model_context_length from unittest.mock import patch as mock_patch - assert DEFAULT_CONTEXT_LENGTHS["kimi-k3"] == 1_000_000 + assert DEFAULT_CONTEXT_LENGTHS["kimi-k3"] == 1_048_576 assert DEFAULT_CONTEXT_LENGTHS["kimi"] == 262144 with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \ mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \ mock_patch("agent.model_metadata.get_cached_context_length", return_value=None): # Kimi K3 (1M) must NOT fall through to the generic 256K entry - assert get_model_context_length("kimi-k3") == 1_000_000 + assert get_model_context_length("kimi-k3") == 1_048_576 # Vendor-prefixed forms (kimi provider, openrouter) - assert get_model_context_length("kimi/kimi-k3") == 1_000_000 - assert get_model_context_length("moonshotai/kimi-k3") == 1_000_000 + assert get_model_context_length("kimi/kimi-k3") == 1_048_576 + assert get_model_context_length("moonshotai/kimi-k3") == 1_048_576 # Older/unknown Kimi models still resolve to 256K fallback assert get_model_context_length("kimi-k2.6") == 262144 assert get_model_context_length("kimi-k2") == 262144 diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py index 69727f9ab778..339e6ab529ee 100644 --- a/tests/agent/test_moonshot_schema.py +++ b/tests/agent/test_moonshot_schema.py @@ -29,6 +29,10 @@ class TestMoonshotModelDetection: [ "kimi-k2.6", "kimi-k2-thinking", + "k3", + "K3", + "moonshotai/k3", + "k3.1-preview", "moonshotai/Kimi-K2.6", "moonshotai/kimi-k2.6", "nous/moonshotai/kimi-k2.6", diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index 78b69e0d0ecc..168dd934f96a 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -97,14 +97,15 @@ class TestProviderModelIdsPreferred: assert "claude-opus-4-7" in out assert "kimi-k2.6" in out - def test_kimi_coding_offline_catalog_includes_k2_7_code(self): - """Native Kimi users must see the newest Code model without live catalog help.""" + def test_kimi_coding_offline_catalog_includes_k3(self): + """Native Kimi users must see the newest models without live catalog help.""" assert "kimi-coding" not in _MODELS_DEV_PREFERRED with patch("agent.models_dev.list_agentic_models", return_value=[]): out = provider_model_ids("kimi-coding") + assert "kimi-k3" in out assert "kimi-k2.7-code" in out - def test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code(self): + def test_kimi_coding_live_catalog_does_not_hide_curated_k3(self): """Kimi /models can lag inference; live results must not replace curated.""" with ( patch( @@ -114,8 +115,8 @@ class TestProviderModelIdsPreferred: patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]), ): out = provider_model_ids("kimi-coding") - # Curated-first order; curated newest (k2.7-code) stays ahead of live. - assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"] + # Curated-first order; curated newest (k3) stays ahead of live. + assert out[:3] == ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6"] def test_k3_live_discovery_is_scoped_to_kimi_coding_endpoint(self): """Coding keys discover K3; legacy Moonshot keys must not advertise it.""" @@ -171,7 +172,7 @@ class TestProviderModelIdsPreferred: custom_models = provider_model_ids("kimi-coding") assert "k3" in coding_models - assert coding_models[0] == "kimi-k2.7-code" + assert coding_models[0] == "kimi-k3" assert all(model.lower() != "k3" for model in legacy_models) assert all(model.lower() != "k3" for model in custom_models) @@ -194,7 +195,7 @@ class TestProviderModelIdsPreferred: _model_flow_kimi({}, current_model="") assert captured["models"] == _PROVIDER_MODELS["kimi-coding"] - assert captured["models"][0] == "kimi-k2.7-code" + assert captured["models"][0] == "kimi-k3" class TestOpenRouterAndNousUnchanged: